| author | |
| committer | |
| log | 8eca20005b47e197b3610f7996f3fd02355c1891 |
| tree | 4e7d3739950cb761867a93d3f5c879edd92ff793 |
| parent | 2fa1fec8d8f63bc8fa4579e7c0fd280b21de02ef |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>26 files changed, 1934 insertions(+), 122 deletions(-)
apps/desktop/src/preview/BrowserSession.ts+4| ... | ... | @@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([ |
| 23 | 23 | "clipboard-sanitized-write", |
| 24 | 24 | "notifications", |
| 25 | 25 | "geolocation", |
| 26 | // Deliberately NOT local-fonts: preview sessions run untrusted web content, | |
| 27 | // and silently granting it would hand every page the user's installed-font | |
| 28 | // fingerprint (and font file bytes via FontData.blob()). The app's own font | |
| 29 | // picker runs in the main window session, which is unaffected by this list. | |
| 26 | 30 | ]); |
| 27 | 31 | |
| 28 | 32 | export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()( |
apps/desktop/src/settings/DesktopClientSettings.test.ts+9| ... | ... | @@ -20,6 +20,15 @@ const clientSettings: ClientSettings = { |
| 20 | 20 | diffIgnoreWhitespace: true, |
| 21 | 21 | environmentIdentificationMode: "artwork", |
| 22 | 22 | favorites: [], |
| 23 | fontFamilyCode: "", | |
| 24 | fontFamilyComposer: "", | |
| 25 | fontFamilySans: "", | |
| 26 | fontFamilyTerminal: "", | |
| 27 | fontSizeCode: 13, | |
| 28 | fontSizeInterface: 16, | |
| 29 | fontSizePrompt: 14, | |
| 30 | fontSizeTerminal: 12, | |
| 31 | fontSmoothing: true, | |
| 23 | 32 | glassOpacity: 80, |
| 24 | 33 | providerModelPreferences: {}, |
| 25 | 34 | sidebarAutoSettleAfterDays: 3, |
apps/web/index.html+1-8| ... | ... | @@ -47,14 +47,7 @@ |
| 47 | 47 | body { |
| 48 | 48 | background: #ffffff; |
| 49 | 49 | color: #262626; |
| 50 | font-family: | |
| 51 | "DM Sans Variable", | |
| 52 | "DM Sans", | |
| 53 | -apple-system, | |
| 54 | BlinkMacSystemFont, | |
| 55 | "Segoe UI", | |
| 56 | system-ui, | |
| 57 | sans-serif; | |
| 50 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | |
| 58 | 51 | } |
| 59 | 52 | |
| 60 | 53 | html.dark body { |
apps/web/package.json-2| ... | ... | @@ -20,8 +20,6 @@ |
| 20 | 20 | "@dnd-kit/sortable": "^10.0.0", |
| 21 | 21 | "@dnd-kit/utilities": "^3.2.2", |
| 22 | 22 | "@effect/atom-react": "catalog:", |
| 23 | "@fontsource-variable/dm-sans": "^5.2.8", | |
| 24 | "@fontsource/jetbrains-mono": "^5.2.8", | |
| 25 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 26 | 24 | "@legendapp/list": "3.2.0", |
| 27 | 25 | "@lexical/react": "^0.41.0", |
apps/web/src/appearanceFonts.test.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | clampCodeFontSize, | |
| 5 | clampInterfaceFontSize, | |
| 6 | clampPromptFontSize, | |
| 7 | DEFAULT_CODE_FONT_STACK, | |
| 8 | DEFAULT_SANS_FONT_STACK, | |
| 9 | appearanceFontStack, | |
| 10 | cssFontFamilies, | |
| 11 | resolveDefaultFamilyLabel, | |
| 12 | } from "./appearanceFonts"; | |
| 13 | ||
| 14 | describe("cssFontFamilies", () => { | |
| 15 | it("returns null for effectively empty input", () => { | |
| 16 | expect(cssFontFamilies("")).toBeNull(); | |
| 17 | expect(cssFontFamilies(" ")).toBeNull(); | |
| 18 | expect(cssFontFamilies(" , , ")).toBeNull(); | |
| 19 | }); | |
| 20 | ||
| 21 | it("quotes names with spaces and keeps single idents bare", () => { | |
| 22 | expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); | |
| 23 | expect(cssFontFamilies("monospace")).toBe("monospace"); | |
| 24 | expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); | |
| 25 | }); | |
| 26 | ||
| 27 | it("normalizes comma-separated lists and strips embedded quotes", () => { | |
| 28 | expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); | |
| 29 | expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); | |
| 30 | }); | |
| 31 | ||
| 32 | it("quotes names that are not single CSS idents", () => { | |
| 33 | expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); | |
| 34 | expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 38 | describe("resolveDefaultFamilyLabel", () => { | |
| 39 | it("skips generic keywords and returns null for a stack of only generics", () => { | |
| 40 | expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); | |
| 41 | expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("appearanceFontStack", () => { | |
| 46 | it("prepends the custom family to the default stack", () => { | |
| 47 | expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( | |
| 48 | `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, | |
| 49 | ); | |
| 50 | }); | |
| 51 | ||
| 52 | it("falls back to the default stack when unset", () => { | |
| 53 | expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("font size clamping", () => { | |
| 58 | it("keeps sizes inside the ranges the UI can absorb", () => { | |
| 59 | expect(clampInterfaceFontSize(16)).toBe(16); | |
| 60 | expect(clampInterfaceFontSize(2)).toBe(12); | |
| 61 | expect(clampInterfaceFontSize(96)).toBe(20); | |
| 62 | expect(clampPromptFontSize(40)).toBe(20); | |
| 63 | expect(clampCodeFontSize(1)).toBe(10); | |
| 64 | }); | |
| 65 | ||
| 66 | it("rounds fractional values and falls back for unusable input", () => { | |
| 67 | expect(clampCodeFontSize(13.4)).toBe(13); | |
| 68 | expect(clampInterfaceFontSize(Number.NaN)).toBe(16); | |
| 69 | expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); | |
| 70 | }); | |
| 71 | }); |
apps/web/src/appearanceFonts.ts created+352| ... | ... | @@ -0,0 +1,352 @@ |
| 1 | /** | |
| 2 | * Font preferences from Settings → Appearance, applied as CSS custom | |
| 3 | * properties. The default stacks mirror the `--font-sans` / `--font-mono` | |
| 4 | * definitions in `index.css`; a custom family is always prepended to the | |
| 5 | * matching default stack so glyph coverage never regresses. | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | DEFAULT_CODE_FONT_SIZE, | |
| 10 | DEFAULT_INTERFACE_FONT_SIZE, | |
| 11 | DEFAULT_PROMPT_FONT_SIZE, | |
| 12 | MAX_CODE_FONT_SIZE, | |
| 13 | MAX_INTERFACE_FONT_SIZE, | |
| 14 | MAX_PROMPT_FONT_SIZE, | |
| 15 | MIN_CODE_FONT_SIZE, | |
| 16 | MIN_INTERFACE_FONT_SIZE, | |
| 17 | MIN_PROMPT_FONT_SIZE, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | ||
| 20 | export const DEFAULT_SANS_FONT_STACK = | |
| 21 | '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; | |
| 22 | ||
| 23 | // Concrete names first: some engines alias `ui-monospace` to the | |
| 24 | // proportional system UI font, which would break every code surface. | |
| 25 | export const DEFAULT_CODE_FONT_STACK = | |
| 26 | '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; | |
| 27 | ||
| 28 | function quoteFontFamilyName(name: string): string { | |
| 29 | const bare = name.trim(); | |
| 30 | if (bare.length === 0) return ""; | |
| 31 | // Already quoted, or a single ident that needs no quoting. | |
| 32 | if (/^(['"]).*\1$/.test(bare)) return bare; | |
| 33 | if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; | |
| 34 | return `"${bare.replaceAll('"', "")}"`; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Normalize a user-entered family (single name or comma-separated list) into a | |
| 39 | * safe CSS font-family list, or null when the input is effectively empty. | |
| 40 | */ | |
| 41 | export function cssFontFamilies(input: string): string | null { | |
| 42 | const families = input | |
| 43 | .split(",") | |
| 44 | .map(quoteFontFamilyName) | |
| 45 | .filter((name) => name.length > 0); | |
| 46 | return families.length > 0 ? families.join(", ") : null; | |
| 47 | } | |
| 48 | ||
| 49 | /** The full stack a preference resolves to: custom families before the default. */ | |
| 50 | export function appearanceFontStack(custom: string, defaultStack: string): string { | |
| 51 | const families = cssFontFamilies(custom); | |
| 52 | return families === null ? defaultStack : `${families}, ${defaultStack}`; | |
| 53 | } | |
| 54 | ||
| 55 | export interface AppearanceFontPreferences { | |
| 56 | readonly sans: string; | |
| 57 | readonly code: string; | |
| 58 | readonly composer: string; | |
| 59 | readonly sizeInterface: number; | |
| 60 | readonly sizePrompt: number; | |
| 61 | readonly sizeCode: number; | |
| 62 | /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ | |
| 63 | readonly smoothing: boolean; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Apply the preferences to the root element. Unset families remove the | |
| 68 | * override so the stylesheet defaults (and theme changes) stay in charge. | |
| 69 | * | |
| 70 | * Sizes are always written: the interface size drives the root font size (and | |
| 71 | * with it every rem-based dimension), while the prompt and code sizes stay in | |
| 72 | * absolute pixels so they do not scale twice. | |
| 73 | */ | |
| 74 | export function applyAppearanceFontVariables( | |
| 75 | root: HTMLElement, | |
| 76 | preferences: AppearanceFontPreferences, | |
| 77 | ): void { | |
| 78 | const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [ | |
| 79 | ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], | |
| 80 | ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], | |
| 81 | // The composer falls back to whatever the sans preference resolves to. | |
| 82 | ["--font-composer", preferences.composer, "var(--font-sans)"], | |
| 83 | ]; | |
| 84 | for (const [variable, custom, fallback] of families) { | |
| 85 | const list = cssFontFamilies(custom); | |
| 86 | if (list === null) { | |
| 87 | root.style.removeProperty(variable); | |
| 88 | } else { | |
| 89 | root.style.setProperty(variable, `${list}, ${fallback}`); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; | |
| 94 | root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); | |
| 95 | const code = clampCodeFontSize(preferences.sizeCode); | |
| 96 | root.style.setProperty("--font-size-code", `${code}px`); | |
| 97 | // The @pierre/diffs surfaces read their own hook for code text. | |
| 98 | root.style.setProperty("--diffs-font-size", `${code}px`); | |
| 99 | ||
| 100 | // Inherited from the root; only macOS engines honor the property, so no | |
| 101 | // platform gate is needed here. Smoothing on means grayscale `antialiased` | |
| 102 | // (thinner strokes); off restores the platform default, which macOS renders | |
| 103 | // with heavier stem darkening. | |
| 104 | if (preferences.smoothing) { | |
| 105 | root.style.setProperty("-webkit-font-smoothing", "antialiased"); | |
| 106 | } else { | |
| 107 | root.style.removeProperty("-webkit-font-smoothing"); | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { | |
| 112 | if (!Number.isFinite(value)) return fallback; | |
| 113 | return Math.min(maximum, Math.max(minimum, Math.round(value))); | |
| 114 | } | |
| 115 | ||
| 116 | export function clampInterfaceFontSize(value: number): number { | |
| 117 | return clampFontSize( | |
| 118 | value, | |
| 119 | MIN_INTERFACE_FONT_SIZE, | |
| 120 | MAX_INTERFACE_FONT_SIZE, | |
| 121 | DEFAULT_INTERFACE_FONT_SIZE, | |
| 122 | ); | |
| 123 | } | |
| 124 | ||
| 125 | export function clampPromptFontSize(value: number): number { | |
| 126 | return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); | |
| 127 | } | |
| 128 | ||
| 129 | export function clampCodeFontSize(value: number): number { | |
| 130 | return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); | |
| 131 | } | |
| 132 | ||
| 133 | const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; | |
| 134 | let fontProbeContext: CanvasRenderingContext2D | null | undefined; | |
| 135 | ||
| 136 | function probeWidth(fontList: string): number | null { | |
| 137 | if (fontProbeContext === undefined) { | |
| 138 | fontProbeContext = document.createElement("canvas").getContext("2d"); | |
| 139 | } | |
| 140 | if (fontProbeContext === null) return null; | |
| 141 | fontProbeContext.font = `16px ${fontList}`; | |
| 142 | return fontProbeContext.measureText(FONT_PROBE_TEXT).width; | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Canvas metric probing instead of document.fonts.check(): check() reports | |
| 147 | * true for families that are not installed at all (nothing needs loading), so | |
| 148 | * it cannot filter the dropdown. A family exists when falling back to at | |
| 149 | * least one generic changes the measured advance. | |
| 150 | */ | |
| 151 | export function isFontFamilyAvailable(family: string): boolean { | |
| 152 | const families = cssFontFamilies(family); | |
| 153 | if (families === null) return false; | |
| 154 | if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; | |
| 155 | try { | |
| 156 | for (const generic of ["monospace", "serif", "sans-serif"]) { | |
| 157 | const baseline = probeWidth(generic); | |
| 158 | const candidate = probeWidth(`${families}, ${generic}`); | |
| 159 | if (baseline === null || candidate === null) return false; | |
| 160 | if (candidate !== baseline) return true; | |
| 161 | } | |
| 162 | return false; | |
| 163 | } catch { | |
| 164 | return false; | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Whether a family renders every character on the same advance. Cell-grid | |
| 170 | * surfaces (the terminal) require this: a proportional face draws its text | |
| 171 | * narrower than the lattice the cursor and selection are placed on, which | |
| 172 | * reads as ragged gaps and a cursor stranded to the right of the text. | |
| 173 | * | |
| 174 | * Unmeasurable environments answer true, so a missing canvas never blocks a | |
| 175 | * legitimate font. | |
| 176 | */ | |
| 177 | export function isMonospaceFamily(family: string): boolean { | |
| 178 | const families = cssFontFamilies(family); | |
| 179 | if (families === null) return true; | |
| 180 | try { | |
| 181 | if (fontProbeContext === undefined) { | |
| 182 | fontProbeContext = document.createElement("canvas").getContext("2d"); | |
| 183 | } | |
| 184 | if (fontProbeContext === null) return true; | |
| 185 | // Fall back to a generic mono so an absent face measures as monospace and | |
| 186 | // is left for the normal fallback chain to resolve. | |
| 187 | fontProbeContext.font = `32px ${families}, monospace`; | |
| 188 | const narrow = fontProbeContext.measureText("i").width; | |
| 189 | const wide = fontProbeContext.measureText("M").width; | |
| 190 | if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; | |
| 191 | return Math.abs(wide - narrow) < 0.5; | |
| 192 | } catch { | |
| 193 | return true; | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | // Nameable faces the platform generics commonly map to, likeliest first. | |
| 198 | // Pixel-comparing a generic against these names the actual face; Apple's own | |
| 199 | // UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform | |
| 200 | // identifies San Francisco itself. | |
| 201 | const SANS_GENERIC_CANDIDATES = [ | |
| 202 | "Segoe UI", | |
| 203 | "Roboto", | |
| 204 | "Noto Sans", | |
| 205 | "Ubuntu", | |
| 206 | "Cantarell", | |
| 207 | "DejaVu Sans", | |
| 208 | "Liberation Sans", | |
| 209 | "Helvetica Neue", | |
| 210 | "Arial", | |
| 211 | ] as const; | |
| 212 | const MONO_GENERIC_CANDIDATES = [ | |
| 213 | "Menlo", | |
| 214 | "Consolas", | |
| 215 | "Cascadia Mono", | |
| 216 | "DejaVu Sans Mono", | |
| 217 | "Ubuntu Mono", | |
| 218 | "Liberation Mono", | |
| 219 | "Noto Sans Mono", | |
| 220 | "Roboto Mono", | |
| 221 | "Monaco", | |
| 222 | "Courier New", | |
| 223 | ] as const; | |
| 224 | ||
| 225 | const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; | |
| 226 | ||
| 227 | /** | |
| 228 | * Advance width of the probe text laid out by the DOM - not canvas, whose | |
| 229 | * generic-family mapping diverges from real rendering (this engine draws | |
| 230 | * `ui-monospace` as the proportional UI font on canvas but not in CSS). | |
| 231 | * Identical widths at this size mean the same face for practical purposes. | |
| 232 | */ | |
| 233 | function measureDomProbeWidth(fontFamily: string): number | null { | |
| 234 | try { | |
| 235 | const body = document.body; | |
| 236 | if (!body) return null; | |
| 237 | const span = document.createElement("span"); | |
| 238 | span.style.cssText = | |
| 239 | "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; | |
| 240 | span.style.fontFamily = fontFamily; | |
| 241 | span.textContent = GENERIC_PROBE_TEXT; | |
| 242 | body.appendChild(span); | |
| 243 | const width = span.getBoundingClientRect().width; | |
| 244 | span.remove(); | |
| 245 | return width > 0 ? width : null; | |
| 246 | } catch { | |
| 247 | return null; | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | function widthsMatch(left: number, right: number): boolean { | |
| 252 | return Math.abs(left - right) < 0.01; | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * Name the concrete face a generic keyword renders as, by measuring the | |
| 257 | * generic against nameable candidates. Null when the face cannot be | |
| 258 | * identified (and the platform gives no definitional answer). | |
| 259 | */ | |
| 260 | function resolveGenericFamilyLabel(generic: string): string | null { | |
| 261 | const lower = generic.toLowerCase(); | |
| 262 | if (lower === "serif") return null; | |
| 263 | const monoLike = lower === "ui-monospace" || lower === "monospace"; | |
| 264 | const genericWidth = measureDomProbeWidth(generic); | |
| 265 | if (genericWidth === null) return null; | |
| 266 | for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { | |
| 267 | if (!isFontFamilyAvailable(candidate)) continue; | |
| 268 | const candidateWidth = measureDomProbeWidth(`"${candidate}"`); | |
| 269 | if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { | |
| 270 | return candidate; | |
| 271 | } | |
| 272 | } | |
| 273 | // No nameable face matched; on Apple platforms that means one of the San | |
| 274 | // Francisco faces, which CSS cannot name. Comparing against -apple-system | |
| 275 | // tells the UI face apart from SF Mono. | |
| 276 | if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { | |
| 277 | const systemWidth = measureDomProbeWidth("-apple-system"); | |
| 278 | if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; | |
| 279 | return monoLike ? "SF Mono" : "SF Pro"; | |
| 280 | } | |
| 281 | return null; | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * The first family of a default stack that will actually render - what the | |
| 286 | * "Default" choice means on this machine. Concrete names are probed for | |
| 287 | * availability; generic keywords are resolved to the face they draw with | |
| 288 | * where identifiable. Null when nothing can be named. | |
| 289 | */ | |
| 290 | export function resolveDefaultFamilyLabel(stack: string): string | null { | |
| 291 | for (const raw of stack.split(",")) { | |
| 292 | const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); | |
| 293 | if (family.length === 0) continue; | |
| 294 | if ( | |
| 295 | /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( | |
| 296 | family, | |
| 297 | ) | |
| 298 | ) { | |
| 299 | const resolved = resolveGenericFamilyLabel(family); | |
| 300 | if (resolved !== null) return resolved; | |
| 301 | continue; | |
| 302 | } | |
| 303 | if (isFontFamilyAvailable(family)) return family; | |
| 304 | } | |
| 305 | return null; | |
| 306 | } | |
| 307 | ||
| 308 | export interface InstalledFontFamiliesResult { | |
| 309 | readonly families: readonly string[]; | |
| 310 | /** | |
| 311 | * "unsupported" - the engine has no Local Font Access API (Safari, | |
| 312 | * Firefox); "denied" - the API exists but the user declined the permission | |
| 313 | * prompt. Both fall back to the curated catalog. | |
| 314 | */ | |
| 315 | readonly status: "granted" | "denied" | "unsupported"; | |
| 316 | } | |
| 317 | ||
| 318 | let installedFamiliesCache: InstalledFontFamiliesResult | null = null; | |
| 319 | ||
| 320 | /** | |
| 321 | * Every installed family via the Local Font Access API (Chromium and | |
| 322 | * Electron). Call from a user gesture: the first call raises the browser's | |
| 323 | * local-fonts permission prompt. A denial is not cached, so reopening the | |
| 324 | * picker can ask again after the user changes the site setting. | |
| 325 | */ | |
| 326 | export async function queryInstalledFontFamilies(): Promise<InstalledFontFamiliesResult> { | |
| 327 | if (installedFamiliesCache !== null) return installedFamiliesCache; | |
| 328 | const query = ( | |
| 329 | window as Window & { | |
| 330 | queryLocalFonts?: () => Promise<ReadonlyArray<{ readonly family: string }>>; | |
| 331 | } | |
| 332 | ).queryLocalFonts; | |
| 333 | if (typeof query !== "function") { | |
| 334 | installedFamiliesCache = { families: [], status: "unsupported" }; | |
| 335 | return installedFamiliesCache; | |
| 336 | } | |
| 337 | try { | |
| 338 | const fonts = await query.call(window); | |
| 339 | const families = [...new Set(fonts.map((font) => font.family))] | |
| 340 | // Dot-prefixed families are macOS-internal UI faces; selecting one is | |
| 341 | // never intended and most refuse to render for web content anyway. | |
| 342 | .filter((family) => !family.startsWith(".")) | |
| 343 | .sort((left, right) => left.localeCompare(right)); | |
| 344 | // A denied permission check resolves with an empty list instead of | |
| 345 | // throwing; no machine has zero fonts, so treat empty as denied. | |
| 346 | if (families.length === 0) return { families: [], status: "denied" }; | |
| 347 | installedFamiliesCache = { families, status: "granted" }; | |
| 348 | return installedFamiliesCache; | |
| 349 | } catch { | |
| 350 | return { families: [], status: "denied" }; | |
| 351 | } | |
| 352 | } |
apps/web/src/components/BranchToolbar.tsx+100-2| ... | ... | @@ -9,7 +9,7 @@ import { |
| 9 | 9 | HistoryIcon, |
| 10 | 10 | MonitorIcon, |
| 11 | 11 | } from "lucide-react"; |
| 12 | import { memo, useCallback, useMemo } from "react"; | |
| 12 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 13 | 13 | |
| 14 | 14 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 15 | 15 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; |
| ... | ... | @@ -214,6 +214,98 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 214 | 214 | ); |
| 215 | 215 | }); |
| 216 | 216 | |
| 217 | /** | |
| 218 | * Collapse the strip's labels to icons only when the text no longer fits. | |
| 219 | * | |
| 220 | * Hidden labels stay measurable (they collapse to invisible absolute boxes, | |
| 221 | * which keep their natural width), so the required width can be recomputed in | |
| 222 | * either state on every pass - no remembered widths that could go stale or | |
| 223 | * latch the strip compact. A small hysteresis keeps the boundary from | |
| 224 | * flapping between states. | |
| 225 | */ | |
| 226 | const COMPACT_EXPAND_HYSTERESIS_PX = 16; | |
| 227 | ||
| 228 | function useLabelsOverflow(element: HTMLDivElement | null): boolean { | |
| 229 | const [overflows, setOverflows] = useState(false); | |
| 230 | // A render-synced mirror instead of useEffectEvent: the compiler memoizes | |
| 231 | // the event callback, which left observers reading the first render's null | |
| 232 | // element forever. | |
| 233 | const stateRef = useRef({ element, overflows }); | |
| 234 | stateRef.current = { element, overflows }; | |
| 235 | ||
| 236 | const measure = useCallback(() => { | |
| 237 | const { element: current, overflows: compact } = stateRef.current; | |
| 238 | if (!current) return; | |
| 239 | const available = current.clientWidth; | |
| 240 | if (available === 0) return; | |
| 241 | // flex-1 stretches the groups to fill the strip, so their own boxes always | |
| 242 | // measure "full". Sum the laid-out content instead, skipping hidden form | |
| 243 | // artifacts and absolutely-positioned nodes (the compact-hidden labels). | |
| 244 | const contentWidth = (parent: Element): number => { | |
| 245 | const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; | |
| 246 | let width = 0; | |
| 247 | let counted = 0; | |
| 248 | for (const child of parent.children) { | |
| 249 | if (!(child instanceof HTMLElement)) continue; | |
| 250 | if (child.offsetWidth <= 1) continue; | |
| 251 | const position = getComputedStyle(child).position; | |
| 252 | if (position === "absolute" || position === "fixed") continue; | |
| 253 | width += child.offsetWidth; | |
| 254 | counted += 1; | |
| 255 | } | |
| 256 | return width + gap * Math.max(0, counted - 1); | |
| 257 | }; | |
| 258 | const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; | |
| 259 | let needed = 0; | |
| 260 | let groups = 0; | |
| 261 | for (const child of current.children) { | |
| 262 | if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; | |
| 263 | needed += contentWidth(child); | |
| 264 | groups += 1; | |
| 265 | } | |
| 266 | needed += stripGap * Math.max(0, groups - 1); | |
| 267 | for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) { | |
| 268 | // The clipping can happen below the marker (SelectValue truncates | |
| 269 | // internally), where the outer span's scrollWidth matches its clipped | |
| 270 | // box. The text's real width is the largest scrollWidth in the subtree. | |
| 271 | let textWidth = label.scrollWidth; | |
| 272 | for (const inner of label.querySelectorAll<HTMLElement>("*")) { | |
| 273 | textWidth = Math.max(textWidth, inner.scrollWidth); | |
| 274 | } | |
| 275 | if (compact) { | |
| 276 | // Compact: the label is squeezed to zero width but keeps reporting | |
| 277 | // the full width it would need when expanded. | |
| 278 | needed += textWidth; | |
| 279 | } else { | |
| 280 | // Expanded: the label is in flow; only the clipped remainder is | |
| 281 | // missing from the content sum. | |
| 282 | needed += Math.max(0, textWidth - label.clientWidth); | |
| 283 | } | |
| 284 | } | |
| 285 | setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); | |
| 286 | }, []); | |
| 287 | ||
| 288 | // Label widths can change without the strip box moving (font family or | |
| 289 | // size preferences), so re-measure on every render as well as on resize | |
| 290 | // and font loads. | |
| 291 | useEffect(() => { | |
| 292 | measure(); | |
| 293 | }); | |
| 294 | ||
| 295 | useEffect(() => { | |
| 296 | if (!element) return; | |
| 297 | const observer = new ResizeObserver(measure); | |
| 298 | observer.observe(element); | |
| 299 | document.fonts.addEventListener("loadingdone", measure); | |
| 300 | return () => { | |
| 301 | observer.disconnect(); | |
| 302 | document.fonts.removeEventListener("loadingdone", measure); | |
| 303 | }; | |
| 304 | }, [element, measure]); | |
| 305 | ||
| 306 | return overflows; | |
| 307 | } | |
| 308 | ||
| 217 | 309 | export const BranchToolbar = memo(function BranchToolbar({ |
| 218 | 310 | environmentId, |
| 219 | 311 | threadId, |
| ... | ... | @@ -300,11 +392,17 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 300 | 392 | canPickEnvironment: showEnvironmentPicker, |
| 301 | 393 | }); |
| 302 | 394 | const isMobile = useIsMobile(); |
| 395 | const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null); | |
| 396 | const labelsOverflow = useLabelsOverflow(stripElement); | |
| 303 | 397 | |
| 304 | 398 | if (!hasActiveThread || !activeProject) return null; |
| 305 | 399 | |
| 306 | 400 | return ( |
| 307 | <div className="chat-composer-context-strip -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1"> | |
| 401 | <div | |
| 402 | ref={setStripElement} | |
| 403 | data-compact={labelsOverflow ? "" : undefined} | |
| 404 | className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1" | |
| 405 | > | |
| 308 | 406 | {isMobile ? ( |
| 309 | 407 | <MobileRunContextSelector |
| 310 | 408 | envLocked={envLocked} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-1| ... | ... | @@ -749,7 +749,12 @@ export function BranchToolbarBranchSelector({ |
| 749 | 749 | disabled={isInitialBranchesLoadPending || isBranchActionPending} |
| 750 | 750 | > |
| 751 | 751 | <GitBranchIcon className="size-3 shrink-0 opacity-70" /> |
| 752 | <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span> | |
| 752 | <span | |
| 753 | data-composer-label | |
| 754 | className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0" | |
| 755 | > | |
| 756 | {triggerLabel} | |
| 757 | </span> | |
| 753 | 758 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| 754 | 759 | </ComboboxTrigger> |
| 755 | 760 | </span> |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+7-2| ... | ... | @@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 82 | 82 | <SelectTrigger |
| 83 | 83 | variant="ghost" |
| 84 | 84 | size="xs" |
| 85 | className="shrink-0 font-medium" | |
| 85 | className="min-w-0 shrink font-medium" | |
| 86 | 86 | aria-label="Workspace" |
| 87 | 87 | > |
| 88 | 88 | {effectiveEnvMode === "worktree" ? ( |
| ... | ... | @@ -92,7 +92,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 92 | 92 | ) : ( |
| 93 | 93 | <FolderIcon className="size-3" /> |
| 94 | 94 | )} |
| 95 | <SelectValue /> | |
| 95 | <span | |
| 96 | data-composer-label | |
| 97 | className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0" | |
| 98 | > | |
| 99 | <SelectValue /> | |
| 100 | </span> | |
| 96 | 101 | </SelectTrigger> |
| 97 | 102 | <SelectPopup> |
| 98 | 103 | <SelectGroup> |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+12-2| ... | ... | @@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 49 | 49 | ) : ( |
| 50 | 50 | <CloudIcon className="size-3 shrink-0" /> |
| 51 | 51 | )} |
| 52 | <span className="truncate">{activeEnvironment?.label ?? "Run on"}</span> | |
| 52 | <span | |
| 53 | data-composer-label | |
| 54 | className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0" | |
| 55 | > | |
| 56 | {activeEnvironment?.label ?? "Run on"} | |
| 57 | </span> | |
| 53 | 58 | </span> |
| 54 | 59 | ); |
| 55 | 60 | } |
| ... | ... | @@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 72 | 77 | ) : ( |
| 73 | 78 | <CloudIcon className="size-3 shrink-0" /> |
| 74 | 79 | )} |
| 75 | <SelectValue /> | |
| 80 | <span | |
| 81 | data-composer-label | |
| 82 | className="min-w-0 max-w-[240px] truncate transition-[max-width,opacity] duration-300 ease-out group-data-[compact]/composer-context:max-w-0 group-data-[compact]/composer-context:opacity-0" | |
| 83 | > | |
| 84 | <SelectValue /> | |
| 85 | </span> | |
| 76 | 86 | </SelectTrigger> |
| 77 | 87 | <SelectPopup> |
| 78 | 88 | <SelectGroup> |
apps/web/src/components/ComposerPromptEditor.tsx+5-3| ... | ... | @@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({ |
| 1747 | 1747 | |
| 1748 | 1748 | return ( |
| 1749 | 1749 | <ComposerTerminalContextActionsContext value={terminalContextActions}> |
| 1750 | <div className="relative"> | |
| 1750 | <div className="composer-editor-surface relative"> | |
| 1751 | 1751 | <PlainTextPlugin |
| 1752 | 1752 | contentEditable={ |
| 1753 | 1753 | <ContentEditable |
| 1754 | 1754 | className={cn( |
| 1755 | "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent text-[16px] leading-relaxed text-foreground focus:outline-none sm:text-[14px]", | |
| 1755 | // The size comes from .composer-editor-surface so Settings -> Appearance | |
| 1756 | // can drive it; keep everything else here. | |
| 1757 | "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", | |
| 1756 | 1758 | className, |
| 1757 | 1759 | )} |
| 1758 | 1760 | data-testid="composer-editor" |
| ... | ... | @@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({ |
| 1763 | 1765 | } |
| 1764 | 1766 | placeholder={ |
| 1765 | 1767 | terminalContexts.length > 0 ? null : ( |
| 1766 | <div className="pointer-events-none absolute inset-0 text-[16px] leading-relaxed text-muted-foreground/35 sm:text-[14px]"> | |
| 1768 | <div className="pointer-events-none absolute inset-0 leading-relaxed text-muted-foreground/35"> | |
| 1767 | 1769 | {placeholder} |
| 1768 | 1770 | </div> |
| 1769 | 1771 | ) |
apps/web/src/components/SidebarV2.tsx+3-1| ... | ... | @@ -757,7 +757,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 757 | 757 | type="button" |
| 758 | 758 | onClick={handlePrClick} |
| 759 | 759 | className={cn( |
| 760 | "shrink-0 font-mono text-xs hover:underline", | |
| 760 | // Sidebar chrome follows the interface font; tabular digits keep the | |
| 761 | // number from reflowing as PR states stream in. | |
| 762 | "shrink-0 text-xs tabular-nums hover:underline", | |
| 761 | 763 | variant === "slim" && variantAction === "unsettle" |
| 762 | 764 | ? props.isActive |
| 763 | 765 | ? "text-muted-foreground/70" |
apps/web/src/components/ThreadTerminalDrawer.tsx+31-1| ... | ... | @@ -56,6 +56,7 @@ import { |
| 56 | 56 | type ThreadTerminalGroup, |
| 57 | 57 | } from "../types"; |
| 58 | 58 | import { readLocalApi } from "~/localApi"; |
| 59 | import { useClientSettings } from "../hooks/useSettings"; | |
| 59 | 60 | import { useAttachedTerminalSession } from "../state/terminalSessions"; |
| 60 | 61 | import { serverEnvironment } from "../state/server"; |
| 61 | 62 | import { previewEnvironment } from "../state/preview"; |
| ... | ... | @@ -131,7 +132,13 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri |
| 131 | 132 | return value ?? fallback; |
| 132 | 133 | } |
| 133 | 134 | |
| 134 | function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { | |
| 135 | /** The surface treats an omitted family or size as "use the built-in default". */ | |
| 136 | function terminalFontOptions(family: string, size: number): { family?: string; size: number } { | |
| 137 | const trimmed = family.trim(); | |
| 138 | return trimmed.length > 0 ? { family: trimmed, size } : { size }; | |
| 139 | } | |
| 140 | ||
| 141 | export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { | |
| 135 | 142 | const isDark = document.documentElement.classList.contains("dark"); |
| 136 | 143 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; |
| 137 | 144 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; |
| ... | ... | @@ -305,6 +312,13 @@ export function TerminalViewport({ |
| 305 | 312 | onAddTerminalContext(selection); |
| 306 | 313 | }); |
| 307 | 314 | const readTerminalLabel = useEffectEvent(() => terminalLabel); |
| 315 | // The terminal inherits the monospace (code) preference unless it has an | |
| 316 | // override of its own, so one font choice drives every mono surface. | |
| 317 | const terminalFontFamily = useClientSettings( | |
| 318 | (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, | |
| 319 | ); | |
| 320 | const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); | |
| 321 | const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); | |
| 308 | 322 | const terminalSession = useAttachedTerminalSession({ |
| 309 | 323 | environmentId, |
| 310 | 324 | terminal: { |
| ... | ... | @@ -367,6 +381,13 @@ export function TerminalViewport({ |
| 367 | 381 | keybindingsRef.current = keybindings; |
| 368 | 382 | }, [keybindings]); |
| 369 | 383 | |
| 384 | useEffect(() => { | |
| 385 | const current = terminalFontRef.current; | |
| 386 | if (current.family === terminalFontFamily && current.size === terminalFontSize) return; | |
| 387 | terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize }; | |
| 388 | void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize)); | |
| 389 | }, [terminalFontFamily, terminalFontSize]); | |
| 390 | ||
| 370 | 391 | useEffect(() => { |
| 371 | 392 | const mount = containerRef.current; |
| 372 | 393 | if (!mount) return; |
| ... | ... | @@ -378,8 +399,10 @@ export function TerminalViewport({ |
| 378 | 399 | let setupCleanups: Array<() => void> = []; |
| 379 | 400 | |
| 380 | 401 | const setup = async (): Promise<(() => void) | null> => { |
| 402 | const setupFont = terminalFontRef.current; | |
| 381 | 403 | const terminalOptions: GhosttyTerminalSurfaceOptions = { |
| 382 | 404 | theme: terminalThemeFromApp(mount), |
| 405 | font: terminalFontOptions(setupFont.family, setupFont.size), | |
| 383 | 406 | onData: (data) => handleData(data), |
| 384 | 407 | onResize: (cols, rows) => void resizeTerminal(cols, rows), |
| 385 | 408 | onSelectionChange: () => handleSelectionChange(), |
| ... | ... | @@ -397,6 +420,13 @@ export function TerminalViewport({ |
| 397 | 420 | terminal.setTheme(terminalThemeFromApp(mount)); |
| 398 | 421 | setupTerminal = terminal; |
| 399 | 422 | terminalRef.current = terminal; |
| 423 | // Client settings hydrate asynchronously; a font preference that landed | |
| 424 | // while the surface was loading found terminalRef null, so its setFont | |
| 425 | // was dropped. Re-apply whatever is current once the terminal exists. | |
| 426 | const currentFont = terminalFontRef.current; | |
| 427 | if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) { | |
| 428 | void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); | |
| 429 | } | |
| 400 | 430 | const latestSession = latestSessionRef.current; |
| 401 | 431 | previousSessionRef.current = latestSession; |
| 402 | 432 | if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); |
apps/web/src/components/composerInlineChip.ts+10-7| ... | ... | @@ -1,20 +1,23 @@ |
| 1 | // Chip metrics are in em so the pills scale with the text they sit in (the | |
| 2 | // composer honors the prompt font-size preference). The chat variant pins the | |
| 3 | // original 12px, where every em value resolves to the same pixels as before. | |
| 1 | 4 | const INLINE_CHIP_CLASS_NAME = |
| 2 | "inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; | |
| 5 | "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle"; | |
| 3 | 6 | |
| 4 | export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME; | |
| 7 | export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`; | |
| 5 | 8 | |
| 6 | export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`; | |
| 9 | export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; | |
| 7 | 10 | |
| 8 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; | |
| 11 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; | |
| 9 | 12 | |
| 10 | 13 | export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; |
| 11 | 14 | |
| 12 | 15 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; |
| 13 | 16 | |
| 14 | 17 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = |
| 15 | "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; | |
| 18 | "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; | |
| 16 | 19 | |
| 17 | export const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`; | |
| 20 | export const SKILL_CHIP_ICON_SVG = `<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`; | |
| 18 | 21 | |
| 19 | 22 | export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = |
| 20 | "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; | |
| 23 | "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; |
apps/web/src/components/settings/FontFamilyPicker.tsx created+254| ... | ... | @@ -0,0 +1,254 @@ |
| 1 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | |
| 2 | import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; | |
| 3 | import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; | |
| 4 | import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; | |
| 5 | import { | |
| 6 | Combobox, | |
| 7 | ComboboxEmpty, | |
| 8 | ComboboxInput, | |
| 9 | ComboboxItem, | |
| 10 | ComboboxListVirtualized, | |
| 11 | ComboboxPopup, | |
| 12 | ComboboxTrigger, | |
| 13 | } from "../ui/combobox"; | |
| 14 | ||
| 15 | const DEFAULT_FONT_VALUE = "__default__"; | |
| 16 | ||
| 17 | function supportsFontEnumeration(): boolean { | |
| 18 | return ( | |
| 19 | typeof window !== "undefined" && | |
| 20 | typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function" | |
| 21 | ); | |
| 22 | } | |
| 23 | ||
| 24 | type FontEnumerationState = | |
| 25 | | { readonly status: "unknown" } | |
| 26 | | { readonly status: "granted"; readonly families: readonly string[] } | |
| 27 | | { readonly status: "unavailable" }; | |
| 28 | ||
| 29 | // Shared across every row: once one picker learns the fonts (or learns the | |
| 30 | // permission is blocked), the others follow without re-querying — and the | |
| 31 | // rows can swap to the plain-input control together. | |
| 32 | let enumerationState: FontEnumerationState = supportsFontEnumeration() | |
| 33 | ? { status: "unknown" } | |
| 34 | : { status: "unavailable" }; | |
| 35 | const enumerationListeners = new Set<() => void>(); | |
| 36 | ||
| 37 | function subscribeToEnumeration(listener: () => void): () => void { | |
| 38 | enumerationListeners.add(listener); | |
| 39 | return () => enumerationListeners.delete(listener); | |
| 40 | } | |
| 41 | ||
| 42 | function readEnumerationState(): FontEnumerationState { | |
| 43 | return enumerationState; | |
| 44 | } | |
| 45 | ||
| 46 | let enumerationLoad: Promise<void> | null = null; | |
| 47 | ||
| 48 | /** Query installed fonts; call from a user gesture (the permission prompt needs one). */ | |
| 49 | export function discoverInstalledFonts(): void { | |
| 50 | if (enumerationState.status !== "unknown" || enumerationLoad !== null) return; | |
| 51 | enumerationLoad = queryInstalledFontFamilies().then((result) => { | |
| 52 | enumerationState = | |
| 53 | result.status === "granted" | |
| 54 | ? { status: "granted", families: result.families } | |
| 55 | : { status: "unavailable" }; | |
| 56 | enumerationLoad = null; | |
| 57 | for (const listener of enumerationListeners) listener(); | |
| 58 | }); | |
| 59 | } | |
| 60 | ||
| 61 | let grantedProbeStarted = false; | |
| 62 | ||
| 63 | /** | |
| 64 | * Discover eagerly when the permission is already granted, so the picker | |
| 65 | * renders without waiting for a focus. Electron's default permission handler | |
| 66 | * approves silently (it has no prompt UI), and a browser that granted once | |
| 67 | * reports "granted" on later visits — in both, no user gesture is needed. | |
| 68 | * "prompt" and "denied" states change nothing: the focus-driven flow stays, | |
| 69 | * because raising the browser prompt still requires a gesture. | |
| 70 | */ | |
| 71 | function probeAlreadyGrantedPermission(): void { | |
| 72 | if (grantedProbeStarted || enumerationState.status !== "unknown") return; | |
| 73 | grantedProbeStarted = true; | |
| 74 | const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined; | |
| 75 | if (typeof permissions?.query !== "function") return; | |
| 76 | permissions.query({ name: "local-fonts" as PermissionName }).then( | |
| 77 | (status) => { | |
| 78 | if (status.state === "granted") discoverInstalledFonts(); | |
| 79 | }, | |
| 80 | () => { | |
| 81 | // The engine does not recognize the permission name; keep the | |
| 82 | // focus-driven flow. | |
| 83 | }, | |
| 84 | ); | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Whether the engine can list installed fonts (Local Font Access API — | |
| 89 | * Chromium and Electron). "unknown" until discovery resolves the permission; | |
| 90 | * rows render a plain family-name input until the state is known granted, | |
| 91 | * then upgrade to the picker. Where the permission is already granted, | |
| 92 | * discovery starts at mount and the picker appears without a focus. | |
| 93 | */ | |
| 94 | export function useFontEnumeration(): FontEnumerationState { | |
| 95 | useEffect(probeAlreadyGrantedPermission, []); | |
| 96 | return useSyncExternalStore(subscribeToEnumeration, readEnumerationState); | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * A searchable picker over every installed family, the way native editors | |
| 101 | * list system fonts. The trigger always names the font in use: the committed | |
| 102 | * family, or what the default stack resolves to on this machine. | |
| 103 | */ | |
| 104 | export function FontFamilyPicker({ | |
| 105 | ariaLabel, | |
| 106 | defaultFamily, | |
| 107 | selectedFamily, | |
| 108 | requireMonospace = false, | |
| 109 | initialOpen = false, | |
| 110 | onSelect, | |
| 111 | }: { | |
| 112 | ariaLabel: string; | |
| 113 | /** What an unset preference renders as, e.g. "Menlo". */ | |
| 114 | defaultFamily: string; | |
| 115 | /** Committed family name; empty string means the default is in use. */ | |
| 116 | selectedFamily: string; | |
| 117 | requireMonospace?: boolean; | |
| 118 | /** Open the popup on mount — set when the control upgrades under focus. */ | |
| 119 | initialOpen?: boolean; | |
| 120 | onSelect: (family: string) => void; | |
| 121 | }) { | |
| 122 | const [open, setOpen] = useState(false); | |
| 123 | const [query, setQuery] = useState(""); | |
| 124 | // Open after mount rather than mounting open: a popup that first renders in | |
| 125 | // its open state never receives Base UI's entrance style baseline, so the | |
| 126 | // exit transition on close has no style delta, never fires transitionend, | |
| 127 | // and the popup lingers on screen forever. | |
| 128 | useEffect(() => { | |
| 129 | if (initialOpen) setOpen(true); | |
| 130 | // The prop is only meaningful at mount - the control just swapped in | |
| 131 | // under an active focus - so later changes are deliberately ignored. | |
| 132 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 133 | }, []); | |
| 134 | const listRef = useRef<LegendListRef | null>(null); | |
| 135 | const enumeration = useFontEnumeration(); | |
| 136 | ||
| 137 | const handleOpenChange = (nextOpen: boolean) => { | |
| 138 | setOpen(nextOpen); | |
| 139 | if (nextOpen) setQuery(""); | |
| 140 | }; | |
| 141 | ||
| 142 | const families = useMemo(() => { | |
| 143 | if (enumeration.status !== "granted") return []; | |
| 144 | return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; | |
| 145 | }, [enumeration, requireMonospace]); | |
| 146 | ||
| 147 | const items = useMemo(() => { | |
| 148 | const trimmedQuery = query.trim().toLowerCase(); | |
| 149 | const result: string[] = []; | |
| 150 | if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE); | |
| 151 | result.push( | |
| 152 | ...families.filter( | |
| 153 | (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery), | |
| 154 | ), | |
| 155 | ); | |
| 156 | return result; | |
| 157 | }, [query, families]); | |
| 158 | ||
| 159 | const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily; | |
| 160 | ||
| 161 | const handlePick = (value: string) => { | |
| 162 | setOpen(false); | |
| 163 | onSelect(value === DEFAULT_FONT_VALUE ? "" : value); | |
| 164 | }; | |
| 165 | ||
| 166 | const renderItem = (item: string, index: number) => { | |
| 167 | const isDefault = item === DEFAULT_FONT_VALUE; | |
| 168 | const family = isDefault ? defaultFamily : item; | |
| 169 | return ( | |
| 170 | <ComboboxItem hideIndicator index={index} key={item} value={item}> | |
| 171 | <div className="flex w-full min-w-0 items-center justify-between gap-2"> | |
| 172 | <span className="min-w-0 truncate" style={{ fontFamily: family }}> | |
| 173 | {family} | |
| 174 | </span> | |
| 175 | <span className="flex shrink-0 items-center gap-1.5"> | |
| 176 | {isDefault ? ( | |
| 177 | <span className="text-[10px] text-muted-foreground/60">default</span> | |
| 178 | ) : null} | |
| 179 | {item === selectedValue ? ( | |
| 180 | <CheckIcon className="size-3.5 text-muted-foreground" /> | |
| 181 | ) : null} | |
| 182 | </span> | |
| 183 | </div> | |
| 184 | </ComboboxItem> | |
| 185 | ); | |
| 186 | }; | |
| 187 | ||
| 188 | return ( | |
| 189 | <Combobox | |
| 190 | items={items} | |
| 191 | filteredItems={items} | |
| 192 | autoHighlight | |
| 193 | virtualized | |
| 194 | open={open} | |
| 195 | onOpenChange={handleOpenChange} | |
| 196 | value={selectedValue} | |
| 197 | onValueChange={(next) => { | |
| 198 | if (typeof next === "string") handlePick(next); | |
| 199 | }} | |
| 200 | onItemHighlighted={(_value, eventDetails) => { | |
| 201 | // Keyboard highlights must pull the virtualized row into view, or | |
| 202 | // arrow keys walk past the rendered window and navigate blind. | |
| 203 | if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return; | |
| 204 | void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false }); | |
| 205 | }} | |
| 206 | > | |
| 207 | <ComboboxTrigger | |
| 208 | aria-label={ariaLabel} | |
| 209 | className="relative inline-flex min-h-9 w-full min-w-36 cursor-pointer select-none items-center justify-between gap-2 rounded-lg border border-input bg-background px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:min-h-8 sm:text-sm dark:bg-input/32" | |
| 210 | > | |
| 211 | <span className="min-w-0 truncate"> | |
| 212 | {selectedFamily.length === 0 ? defaultFamily : selectedFamily} | |
| 213 | </span> | |
| 214 | <ChevronDownIcon className="-me-1 size-3 shrink-0 text-muted-foreground opacity-50" /> | |
| 215 | </ComboboxTrigger> | |
| 216 | <ComboboxPopup align="end" className="flex w-72 flex-col"> | |
| 217 | <div className="shrink-0 px-3 pt-2.5"> | |
| 218 | <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring"> | |
| 219 | <SearchIcon | |
| 220 | aria-hidden="true" | |
| 221 | className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55" | |
| 222 | /> | |
| 223 | <ComboboxInput | |
| 224 | className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5" | |
| 225 | inputClassName="rounded-none bg-transparent text-sm" | |
| 226 | placeholder="Search fonts…" | |
| 227 | showTrigger={false} | |
| 228 | size="sm" | |
| 229 | unstyled | |
| 230 | value={query} | |
| 231 | onChange={(event) => setQuery(event.target.value)} | |
| 232 | /> | |
| 233 | </div> | |
| 234 | </div> | |
| 235 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | |
| 236 | <ComboboxEmpty>No fonts found.</ComboboxEmpty> | |
| 237 | <div className="relative min-h-0 max-h-72 w-full flex-1 overflow-hidden"> | |
| 238 | <ComboboxListVirtualized className="size-full min-w-0 p-0"> | |
| 239 | <LegendList<string> | |
| 240 | ref={listRef} | |
| 241 | data={items} | |
| 242 | keyExtractor={(item) => item} | |
| 243 | renderItem={({ item, index }) => renderItem(item, index)} | |
| 244 | estimatedItemSize={30} | |
| 245 | drawDistance={360} | |
| 246 | style={{ height: Math.min(items.length * 30, 288) }} | |
| 247 | /> | |
| 248 | </ComboboxListVirtualized> | |
| 249 | </div> | |
| 250 | </div> | |
| 251 | </ComboboxPopup> | |
| 252 | </Combobox> | |
| 253 | ); | |
| 254 | } |
apps/web/src/components/settings/SettingsFontPreviews.tsx created+232| ... | ... | @@ -0,0 +1,232 @@ |
| 1 | import { preloadPatchFile } from "@pierre/diffs/ssr"; | |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 3 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; | |
| 4 | import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; | |
| 5 | import { useTheme } from "../../hooks/useTheme"; | |
| 6 | import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; | |
| 7 | import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; | |
| 8 | ||
| 9 | // The font previews are the real surfaces, not lookalikes: the composer's | |
| 10 | // Lexical editor, the diff panel's file diff, and the Ghostty canvas | |
| 11 | // renderer. Each already consumes the appearance font tokens (or, for the | |
| 12 | // terminal, the settings passed down as props), so what the row shows is | |
| 13 | // exactly what the app renders. | |
| 14 | ||
| 15 | const EMPTY_TERMINAL_CONTEXTS: ReadonlyArray<never> = []; | |
| 16 | const EMPTY_SKILLS: ReadonlyArray<never> = []; | |
| 17 | ||
| 18 | // Serialized the way the composer stores inline tokens: the $skill and the | |
| 19 | // markdown-style file links render as chips, so the preview shows prompt | |
| 20 | // text and pills exactly as the real composer draws them. | |
| 21 | const PROMPT_PREVIEW_TEXT = | |
| 22 | "Use $frontend-design to fix the flaky test in " + | |
| 23 | "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " + | |
| 24 | "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; | |
| 25 | ||
| 26 | function noop() {} | |
| 27 | ||
| 28 | /** A live composer editor: type in it to feel the family and size. */ | |
| 29 | export function PromptFontPreview() { | |
| 30 | const editorRef = useRef<ComposerPromptEditorHandle>(null); | |
| 31 | const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); | |
| 32 | const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); | |
| 33 | const onChange = useCallback((nextValue: string, nextCursor: number) => { | |
| 34 | setPrompt(nextValue); | |
| 35 | setCursor(nextCursor); | |
| 36 | }, []); | |
| 37 | return ( | |
| 38 | <div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2"> | |
| 39 | <ComposerPromptEditor | |
| 40 | editorRef={editorRef} | |
| 41 | value={prompt} | |
| 42 | cursor={cursor} | |
| 43 | terminalContexts={EMPTY_TERMINAL_CONTEXTS} | |
| 44 | skills={EMPTY_SKILLS} | |
| 45 | disabled={false} | |
| 46 | placeholder="Ask for follow-up changes or attach images" | |
| 47 | className="max-h-40 min-h-12" | |
| 48 | onRemoveTerminalContext={noop} | |
| 49 | onChange={onChange} | |
| 50 | onPaste={noop} | |
| 51 | /> | |
| 52 | </div> | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | const DIFF_PREVIEW_PATCH = [ | |
| 57 | "diff --git a/src/formatUser.ts b/src/formatUser.ts", | |
| 58 | "--- a/src/formatUser.ts", | |
| 59 | "+++ b/src/formatUser.ts", | |
| 60 | "@@ -1,3 +1,3 @@", | |
| 61 | " export function formatUser(user: User) {", | |
| 62 | "- return user.name.toUpperCase();", | |
| 63 | "+ return `${user.name} <${user.email}>`; // 0O 1lI", | |
| 64 | " }", | |
| 65 | "", | |
| 66 | ].join("\n"); | |
| 67 | ||
| 68 | // Rendered once per theme through the SSR pipeline, which always awaits the | |
| 69 | // shared highlighter before producing HTML. The interactive FileDiff's mount | |
| 70 | // lifecycle can race that highlighter when the typography views remount it | |
| 71 | // (toggling Advanced) and lock in an unhighlighted frame; a static preview | |
| 72 | // needs none of that lifecycle, so it uses the deterministic renderer and | |
| 73 | // injects the finished HTML into a shadow root, exactly as FileDiff would. | |
| 74 | const diffPreviewHtmlByTheme = new Map<DiffThemeName, Promise<readonly string[]>>(); | |
| 75 | ||
| 76 | function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> { | |
| 77 | let promise = diffPreviewHtmlByTheme.get(theme); | |
| 78 | if (promise === undefined) { | |
| 79 | promise = preloadPatchFile({ | |
| 80 | patch: DIFF_PREVIEW_PATCH, | |
| 81 | options: { diffStyle: "unified", theme }, | |
| 82 | }).then((results) => results.map((result) => result.prerenderedHTML)); | |
| 83 | diffPreviewHtmlByTheme.set(theme, promise); | |
| 84 | } | |
| 85 | return promise; | |
| 86 | } | |
| 87 | ||
| 88 | function StaticDiffHtml({ html }: { html: string }) { | |
| 89 | const hostRef = useRef<HTMLDivElement | null>(null); | |
| 90 | useEffect(() => { | |
| 91 | const host = hostRef.current; | |
| 92 | if (host === null) return; | |
| 93 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); | |
| 94 | shadow.innerHTML = html; | |
| 95 | }, [html]); | |
| 96 | return <div ref={hostRef} />; | |
| 97 | } | |
| 98 | ||
| 99 | /** The diff panel's file diff, statically rendered by its real pipeline. */ | |
| 100 | export function CodeFontPreview() { | |
| 101 | const { resolvedTheme } = useTheme(); | |
| 102 | const themeName = resolveDiffThemeName(resolvedTheme); | |
| 103 | const [htmlByFile, setHtmlByFile] = useState<readonly string[] | null>(null); | |
| 104 | useEffect(() => { | |
| 105 | let cancelled = false; | |
| 106 | void loadDiffPreviewHtml(themeName).then((html) => { | |
| 107 | if (!cancelled) setHtmlByFile(html); | |
| 108 | }); | |
| 109 | return () => { | |
| 110 | cancelled = true; | |
| 111 | }; | |
| 112 | }, [themeName]); | |
| 113 | if (htmlByFile === null) return null; | |
| 114 | return ( | |
| 115 | <div className="mt-1 mb-2 space-y-2"> | |
| 116 | {htmlByFile.map((html, index) => ( | |
| 117 | <StaticDiffHtml key={index} html={html} /> | |
| 118 | ))} | |
| 119 | </div> | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | const TERMINAL_PROMPT = "\x1b[2m$\x1b[0m "; | |
| 124 | const TERMINAL_PREVIEW_TRANSCRIPT = | |
| 125 | `${TERMINAL_PROMPT}npm run dev\r\n` + | |
| 126 | "\x1b[32m✓\x1b[0m Ready in 430ms\r\n" + | |
| 127 | "\x1b[2mLocal:\x1b[0m \x1b[36mhttp://localhost:3000\x1b[0m\r\n" + | |
| 128 | TERMINAL_PROMPT; | |
| 129 | ||
| 130 | /** The surface treats an omitted family or size as "use the built-in default". */ | |
| 131 | function previewTerminalFont(family: string, size: number): { family?: string; size: number } { | |
| 132 | const trimmed = family.trim(); | |
| 133 | return trimmed.length > 0 ? { family: trimmed, size } : { size }; | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * The real Ghostty canvas renderer against a local echo loop instead of a | |
| 138 | * PTY: keys print, Enter starts a new prompt line, Backspace erases. That | |
| 139 | * exercises the same glyph atlas, cell metrics, and monospace gate the | |
| 140 | * terminal drawer uses. | |
| 141 | */ | |
| 142 | export function TerminalFontPreview({ family, size }: { family: string; size: number }) { | |
| 143 | const mountRef = useRef<HTMLDivElement>(null); | |
| 144 | const surfaceRef = useRef<GhosttyTerminalSurface | null>(null); | |
| 145 | const fontRef = useRef({ family, size }); | |
| 146 | const { resolvedTheme } = useTheme(); | |
| 147 | ||
| 148 | useEffect(() => { | |
| 149 | const current = fontRef.current; | |
| 150 | if (current.family === family && current.size === size) return; | |
| 151 | fontRef.current = { family, size }; | |
| 152 | void surfaceRef.current?.setFont(previewTerminalFont(family, size)); | |
| 153 | }, [family, size]); | |
| 154 | ||
| 155 | useEffect(() => { | |
| 156 | const mount = mountRef.current; | |
| 157 | const surface = surfaceRef.current; | |
| 158 | if (!mount || !surface) return; | |
| 159 | surface.setTheme(terminalThemeFromApp(mount)); | |
| 160 | }, [resolvedTheme]); | |
| 161 | ||
| 162 | useEffect(() => { | |
| 163 | const mount = mountRef.current; | |
| 164 | if (!mount) return; | |
| 165 | let cancelled = false; | |
| 166 | // Column of the caret on the current input line, so Backspace stops at | |
| 167 | // the prompt instead of eating it. | |
| 168 | let lineLength = 0; | |
| 169 | ||
| 170 | const echo = (data: string) => { | |
| 171 | const surface = surfaceRef.current; | |
| 172 | if (!surface) return; | |
| 173 | if (data === "\r") { | |
| 174 | surface.write(`\r\n${TERMINAL_PROMPT}`); | |
| 175 | lineLength = 0; | |
| 176 | return; | |
| 177 | } | |
| 178 | if (data === "\x7f" || data === "\b") { | |
| 179 | if (lineLength > 0) { | |
| 180 | surface.write("\b \b"); | |
| 181 | lineLength -= 1; | |
| 182 | } | |
| 183 | return; | |
| 184 | } | |
| 185 | // Arrow keys and other escape reports have no cursor to move here. | |
| 186 | if (data.startsWith("\x1b")) return; | |
| 187 | const printable = [...data] | |
| 188 | .filter((character) => character >= " " && character !== "\x7f") | |
| 189 | .join(""); | |
| 190 | if (printable.length === 0) return; | |
| 191 | surface.write(printable); | |
| 192 | lineLength += printable.length; | |
| 193 | }; | |
| 194 | ||
| 195 | void GhosttyTerminalSurface.create(mount, { | |
| 196 | theme: terminalThemeFromApp(mount), | |
| 197 | font: previewTerminalFont(fontRef.current.family, fontRef.current.size), | |
| 198 | onData: echo, | |
| 199 | onResize: noop, | |
| 200 | onSelectionChange: noop, | |
| 201 | onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop), | |
| 202 | // Tab keeps walking the settings page instead of feeding the echo loop. | |
| 203 | beforeKey: (event) => event.key !== "Tab", | |
| 204 | onLinkActivate: noop, | |
| 205 | }).then((surface) => { | |
| 206 | if (cancelled) { | |
| 207 | surface.dispose(); | |
| 208 | return; | |
| 209 | } | |
| 210 | surfaceRef.current = surface; | |
| 211 | // The theme and font may both have changed while the WASM surface loaded. | |
| 212 | surface.setTheme(terminalThemeFromApp(mount)); | |
| 213 | const font = fontRef.current; | |
| 214 | void surface.setFont(previewTerminalFont(font.family, font.size)); | |
| 215 | surface.write(TERMINAL_PREVIEW_TRANSCRIPT); | |
| 216 | }); | |
| 217 | ||
| 218 | return () => { | |
| 219 | cancelled = true; | |
| 220 | surfaceRef.current?.dispose(); | |
| 221 | surfaceRef.current = null; | |
| 222 | }; | |
| 223 | }, []); | |
| 224 | ||
| 225 | return ( | |
| 226 | <div | |
| 227 | ref={mountRef} | |
| 228 | className="relative mt-1 mb-2 h-36 overflow-hidden rounded-lg border border-border" | |
| 229 | aria-label="Terminal font preview" | |
| 230 | /> | |
| 231 | ); | |
| 232 | } |
apps/web/src/components/settings/SettingsPanels.tsx+506-28| ... | ... | @@ -8,8 +8,8 @@ import { |
| 8 | 8 | SettingsIcon, |
| 9 | 9 | } from "lucide-react"; |
| 10 | 10 | import { Link } from "@tanstack/react-router"; |
| 11 | import type { CSSProperties } from "react"; | |
| 12 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 11 | import type { CSSProperties, ReactNode } from "react"; | |
| 12 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 13 | 13 | import { useAtomValue } from "@effect/atom-react"; |
| 14 | 14 | import { |
| 15 | 15 | defaultInstanceIdForDriver, |
| ... | ... | @@ -34,8 +34,16 @@ import { |
| 34 | 34 | DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, |
| 35 | 35 | DEFAULT_UNIFIED_SETTINGS, |
| 36 | 36 | type EnvironmentIdentificationMode, |
| 37 | MAX_CODE_FONT_SIZE, | |
| 37 | 38 | MAX_GLASS_OPACITY, |
| 39 | MAX_INTERFACE_FONT_SIZE, | |
| 40 | MAX_PROMPT_FONT_SIZE, | |
| 41 | MAX_TERMINAL_FONT_SIZE, | |
| 42 | MIN_CODE_FONT_SIZE, | |
| 38 | 43 | MIN_GLASS_OPACITY, |
| 44 | MIN_INTERFACE_FONT_SIZE, | |
| 45 | MIN_PROMPT_FONT_SIZE, | |
| 46 | MIN_TERMINAL_FONT_SIZE, | |
| 39 | 47 | } from "@t3tools/contracts/settings"; |
| 40 | 48 | import { |
| 41 | 49 | getBackgroundActivityBaseProfile, |
| ... | ... | @@ -47,6 +55,7 @@ import * as Arr from "effect/Array"; |
| 47 | 55 | import * as Duration from "effect/Duration"; |
| 48 | 56 | import * as Equal from "effect/Equal"; |
| 49 | 57 | import * as Result from "effect/Result"; |
| 58 | import * as Schema from "effect/Schema"; | |
| 50 | 59 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 51 | 60 | import { |
| 52 | 61 | canCheckForUpdate, |
| ... | ... | @@ -64,6 +73,7 @@ import { |
| 64 | 73 | import { isElectron } from "../../env"; |
| 65 | 74 | import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; |
| 66 | 75 | import { useTheme } from "../../hooks/useTheme"; |
| 76 | import { useLocalStorage } from "../../hooks/useLocalStorage"; | |
| 67 | 77 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 68 | 78 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| 69 | 79 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| ... | ... | @@ -77,6 +87,7 @@ import { |
| 77 | 87 | sortProviderInstanceEntries, |
| 78 | 88 | } from "../../providerInstances"; |
| 79 | 89 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 90 | import { isMacPlatform } from "../../lib/utils"; | |
| 80 | 91 | import { |
| 81 | 92 | primaryServerObservabilityAtom, |
| 82 | 93 | primaryServerProvidersAtom, |
| ... | ... | @@ -97,6 +108,17 @@ import { |
| 97 | 108 | DialogTitle, |
| 98 | 109 | } from "../ui/dialog"; |
| 99 | 110 | import { DraftInput } from "../ui/draft-input"; |
| 111 | import { Input } from "../ui/input"; | |
| 112 | import { | |
| 113 | DEFAULT_CODE_FONT_STACK, | |
| 114 | DEFAULT_SANS_FONT_STACK, | |
| 115 | isFontFamilyAvailable, | |
| 116 | isMonospaceFamily, | |
| 117 | resolveDefaultFamilyLabel, | |
| 118 | } from "../../appearanceFonts"; | |
| 119 | import { DEFAULT_TERMINAL_FONT_FAMILY } from "~/terminal/ghostty/surface"; | |
| 120 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; | |
| 121 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; | |
| 100 | 122 | import { |
| 101 | 123 | NumberField, |
| 102 | 124 | NumberFieldDecrement, |
| ... | ... | @@ -135,6 +157,7 @@ import { |
| 135 | 157 | SettingsRow, |
| 136 | 158 | SettingsSection, |
| 137 | 159 | useRelativeTimeTick, |
| 160 | useSettingsSearchTargetId, | |
| 138 | 161 | } from "./settingsLayout"; |
| 139 | 162 | import { searchableSetting } from "./settingsSearch"; |
| 140 | 163 | import { ProjectFavicon } from "../ProjectFavicon"; |
| ... | ... | @@ -586,6 +609,16 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 586 | 609 | ? ["Project Grouping"] |
| 587 | 610 | : []), |
| 588 | 611 | ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), |
| 612 | ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans | |
| 613 | ? ["Interface font"] | |
| 614 | : []), | |
| 615 | ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer | |
| 616 | ? ["Prompt font"] | |
| 617 | : []), | |
| 618 | ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []), | |
| 619 | ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal | |
| 620 | ? ["Terminal font"] | |
| 621 | : []), | |
| 589 | 622 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| 590 | 623 | ? ["Diff whitespace changes"] |
| 591 | 624 | : []), |
| ... | ... | @@ -629,6 +662,14 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 629 | 662 | settings.newWorktreesStartFromOrigin, |
| 630 | 663 | settings.diffIgnoreWhitespace, |
| 631 | 664 | settings.environmentIdentificationMode, |
| 665 | settings.fontFamilyCode, | |
| 666 | settings.fontFamilyComposer, | |
| 667 | settings.fontFamilySans, | |
| 668 | settings.fontFamilyTerminal, | |
| 669 | settings.fontSizeCode, | |
| 670 | settings.fontSizeInterface, | |
| 671 | settings.fontSizePrompt, | |
| 672 | settings.fontSizeTerminal, | |
| 632 | 673 | settings.glassOpacity, |
| 633 | 674 | settings.enableAssistantStreaming, |
| 634 | 675 | settings.enableProviderUpdateChecks, |
| ... | ... | @@ -672,6 +713,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 672 | 713 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
| 673 | 714 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, |
| 674 | 715 | textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, |
| 716 | fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, | |
| 717 | fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, | |
| 718 | fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, | |
| 719 | fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, | |
| 675 | 720 | }); |
| 676 | 721 | onRestored?.(); |
| 677 | 722 | }, [changedSettingLabels, onRestored, setTheme, updateSettings]); |
| ... | ... | @@ -958,8 +1003,8 @@ export function AppearanceSettingsPanel() { |
| 958 | 1003 | const glassOpacityRatio = |
| 959 | 1004 | (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); |
| 960 | 1005 | const glassOpacitySliderStyle = { |
| 961 | "--glass-slider-progress": `${glassOpacityRatio * 100}%`, | |
| 962 | "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, | |
| 1006 | "--settings-slider-progress": `${glassOpacityRatio * 100}%`, | |
| 1007 | "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, | |
| 963 | 1008 | } as CSSProperties; |
| 964 | 1009 | |
| 965 | 1010 | return ( |
| ... | ... | @@ -1021,7 +1066,7 @@ export function AppearanceSettingsPanel() { |
| 1021 | 1066 | </output> |
| 1022 | 1067 | <input |
| 1023 | 1068 | aria-label="Glass opacity" |
| 1024 | className="glass-opacity-slider min-w-0 flex-1" | |
| 1069 | className="settings-slider min-w-0 flex-1" | |
| 1025 | 1070 | id="glass-opacity" |
| 1026 | 1071 | max={MAX_GLASS_OPACITY} |
| 1027 | 1072 | min={MIN_GLASS_OPACITY} |
| ... | ... | @@ -1085,32 +1130,465 @@ export function AppearanceSettingsPanel() { |
| 1085 | 1130 | } |
| 1086 | 1131 | /> |
| 1087 | 1132 | ) : null} |
| 1133 | </SettingsSection> | |
| 1088 | 1134 | |
| 1089 | <SettingsRow | |
| 1090 | {...searchableSetting("word-wrap")} | |
| 1091 | description="Wrap long lines in code blocks, tables, diffs, and file previews by default." | |
| 1092 | resetAction={ | |
| 1093 | settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ( | |
| 1094 | <SettingResetButton | |
| 1095 | label="word wrapping" | |
| 1096 | onClick={() => | |
| 1097 | updateSettings({ | |
| 1098 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | |
| 1099 | }) | |
| 1100 | } | |
| 1101 | /> | |
| 1102 | ) : null | |
| 1103 | } | |
| 1104 | control={ | |
| 1105 | <Switch | |
| 1106 | checked={settings.wordWrap} | |
| 1107 | onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })} | |
| 1108 | aria-label="Wrap code, tables, diffs, and file previews by default" | |
| 1135 | <TypographySection /> | |
| 1136 | </SettingsPageContainer> | |
| 1137 | ); | |
| 1138 | } | |
| 1139 | ||
| 1140 | function useFontDefaultFamilies() { | |
| 1141 | const settings = usePrimarySettings(); | |
| 1142 | // An unset preference shows the font it resolves to on this machine; the | |
| 1143 | // default stacks are the platform's own faces, so the name is probed, not | |
| 1144 | // hardcoded. | |
| 1145 | const defaults = useMemo( | |
| 1146 | () => ({ | |
| 1147 | sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", | |
| 1148 | code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", | |
| 1149 | }), | |
| 1150 | [], | |
| 1151 | ); | |
| 1152 | return { | |
| 1153 | sans: defaults.sans, | |
| 1154 | code: defaults.code, | |
| 1155 | // The composer inherits whatever the interface preference resolves to; | |
| 1156 | // the terminal inherits the monospace preference the same way. | |
| 1157 | interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, | |
| 1158 | monoFamily: settings.fontFamilyCode.trim() || defaults.code, | |
| 1159 | }; | |
| 1160 | } | |
| 1161 | ||
| 1162 | function InterfaceFontRow({ preview }: { preview?: ReactNode }) { | |
| 1163 | const settings = usePrimarySettings(); | |
| 1164 | const updateSettings = useUpdatePrimarySettings(); | |
| 1165 | const defaults = useFontDefaultFamilies(); | |
| 1166 | return ( | |
| 1167 | <FontFamilySettingsRow | |
| 1168 | {...searchableSetting("interface-font")} | |
| 1169 | description="Everything outside code blocks and the terminal." | |
| 1170 | defaultFamily={defaults.sans} | |
| 1171 | value={settings.fontFamilySans} | |
| 1172 | onValueChange={(fontFamilySans) => updateSettings({ fontFamilySans })} | |
| 1173 | size={{ | |
| 1174 | label: "Interface font size", | |
| 1175 | min: MIN_INTERFACE_FONT_SIZE, | |
| 1176 | max: MAX_INTERFACE_FONT_SIZE, | |
| 1177 | value: settings.fontSizeInterface, | |
| 1178 | onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), | |
| 1179 | }} | |
| 1180 | {...(preview !== undefined ? { preview } : {})} | |
| 1181 | /> | |
| 1182 | ); | |
| 1183 | } | |
| 1184 | ||
| 1185 | function PromptFontRow() { | |
| 1186 | const settings = usePrimarySettings(); | |
| 1187 | const updateSettings = useUpdatePrimarySettings(); | |
| 1188 | const defaults = useFontDefaultFamilies(); | |
| 1189 | return ( | |
| 1190 | <FontFamilySettingsRow | |
| 1191 | {...searchableSetting("prompt-font")} | |
| 1192 | description="Only the box you write prompts in. Mono works well here." | |
| 1193 | defaultFamily={defaults.interfaceFamily} | |
| 1194 | value={settings.fontFamilyComposer} | |
| 1195 | onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} | |
| 1196 | size={{ | |
| 1197 | label: "Prompt font size", | |
| 1198 | min: MIN_PROMPT_FONT_SIZE, | |
| 1199 | max: MAX_PROMPT_FONT_SIZE, | |
| 1200 | value: settings.fontSizePrompt, | |
| 1201 | onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), | |
| 1202 | }} | |
| 1203 | preview={<PromptFontPreview />} | |
| 1204 | /> | |
| 1205 | ); | |
| 1206 | } | |
| 1207 | ||
| 1208 | function CodeFontRow({ | |
| 1209 | title, | |
| 1210 | description = "Code blocks, diffs, and file previews.", | |
| 1211 | preview, | |
| 1212 | }: { | |
| 1213 | title?: string; | |
| 1214 | description?: string; | |
| 1215 | preview?: ReactNode; | |
| 1216 | }) { | |
| 1217 | const settings = usePrimarySettings(); | |
| 1218 | const updateSettings = useUpdatePrimarySettings(); | |
| 1219 | const defaults = useFontDefaultFamilies(); | |
| 1220 | return ( | |
| 1221 | <FontFamilySettingsRow | |
| 1222 | {...searchableSetting("code-font")} | |
| 1223 | {...(title !== undefined ? { title } : {})} | |
| 1224 | description={description} | |
| 1225 | defaultFamily={defaults.code} | |
| 1226 | value={settings.fontFamilyCode} | |
| 1227 | onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })} | |
| 1228 | requireMonospace | |
| 1229 | size={{ | |
| 1230 | label: "Code font size", | |
| 1231 | min: MIN_CODE_FONT_SIZE, | |
| 1232 | max: MAX_CODE_FONT_SIZE, | |
| 1233 | value: settings.fontSizeCode, | |
| 1234 | onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), | |
| 1235 | }} | |
| 1236 | preview={preview ?? <CodeFontPreview />} | |
| 1237 | /> | |
| 1238 | ); | |
| 1239 | } | |
| 1240 | ||
| 1241 | function TerminalFontRow() { | |
| 1242 | const settings = usePrimarySettings(); | |
| 1243 | const updateSettings = useUpdatePrimarySettings(); | |
| 1244 | const defaults = useFontDefaultFamilies(); | |
| 1245 | return ( | |
| 1246 | <FontFamilySettingsRow | |
| 1247 | {...searchableSetting("terminal-font")} | |
| 1248 | description="Terminal output. Follows the monospace font unless set." | |
| 1249 | defaultFamily={defaults.monoFamily} | |
| 1250 | value={settings.fontFamilyTerminal} | |
| 1251 | onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })} | |
| 1252 | requireMonospace | |
| 1253 | size={{ | |
| 1254 | label: "Terminal font size", | |
| 1255 | min: MIN_TERMINAL_FONT_SIZE, | |
| 1256 | max: MAX_TERMINAL_FONT_SIZE, | |
| 1257 | value: settings.fontSizeTerminal, | |
| 1258 | onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), | |
| 1259 | }} | |
| 1260 | preview={ | |
| 1261 | <TerminalFontPreview | |
| 1262 | family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode} | |
| 1263 | size={settings.fontSizeTerminal} | |
| 1264 | /> | |
| 1265 | } | |
| 1266 | /> | |
| 1267 | ); | |
| 1268 | } | |
| 1269 | ||
| 1270 | function FontSmoothingRow() { | |
| 1271 | const settings = usePrimarySettings(); | |
| 1272 | const updateSettings = useUpdatePrimarySettings(); | |
| 1273 | if (!isMacPlatform(navigator.platform)) return null; | |
| 1274 | return ( | |
| 1275 | <SettingsRow | |
| 1276 | {...searchableSetting("font-smoothing")} | |
| 1277 | description="Render text with thinner grayscale anti-aliasing instead of macOS's heavier default." | |
| 1278 | resetAction={ | |
| 1279 | settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? ( | |
| 1280 | <SettingResetButton | |
| 1281 | label="font smoothing" | |
| 1282 | onClick={() => | |
| 1283 | updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) | |
| 1284 | } | |
| 1285 | /> | |
| 1286 | ) : null | |
| 1287 | } | |
| 1288 | control={ | |
| 1289 | <Switch | |
| 1290 | checked={settings.fontSmoothing} | |
| 1291 | onCheckedChange={(checked) => updateSettings({ fontSmoothing: Boolean(checked) })} | |
| 1292 | aria-label="Font smoothing" | |
| 1293 | /> | |
| 1294 | } | |
| 1295 | /> | |
| 1296 | ); | |
| 1297 | } | |
| 1298 | ||
| 1299 | function WordWrapRow() { | |
| 1300 | const settings = usePrimarySettings(); | |
| 1301 | const updateSettings = useUpdatePrimarySettings(); | |
| 1302 | return ( | |
| 1303 | <SettingsRow | |
| 1304 | {...searchableSetting("word-wrap")} | |
| 1305 | description="Wrap long lines in code blocks, tables, diffs, and file previews by default." | |
| 1306 | resetAction={ | |
| 1307 | settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ( | |
| 1308 | <SettingResetButton | |
| 1309 | label="word wrapping" | |
| 1310 | onClick={() => updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })} | |
| 1311 | /> | |
| 1312 | ) : null | |
| 1313 | } | |
| 1314 | control={ | |
| 1315 | <Switch | |
| 1316 | checked={settings.wordWrap} | |
| 1317 | onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })} | |
| 1318 | aria-label="Wrap code, tables, diffs, and file previews by default" | |
| 1319 | /> | |
| 1320 | } | |
| 1321 | /> | |
| 1322 | ); | |
| 1323 | } | |
| 1324 | ||
| 1325 | function FontSettingsGroup() { | |
| 1326 | return ( | |
| 1327 | <> | |
| 1328 | <InterfaceFontRow /> | |
| 1329 | <PromptFontRow /> | |
| 1330 | <CodeFontRow /> | |
| 1331 | <TerminalFontRow /> | |
| 1332 | <FontSmoothingRow /> | |
| 1333 | </> | |
| 1334 | ); | |
| 1335 | } | |
| 1336 | ||
| 1337 | /** | |
| 1338 | * The two-font view: one sans, one monospace. The prompt follows the | |
| 1339 | * interface font and the terminal follows the monospace font, so the demos | |
| 1340 | * under each row show every surface the choice reaches. | |
| 1341 | */ | |
| 1342 | function SimpleFontRows() { | |
| 1343 | const settings = usePrimarySettings(); | |
| 1344 | return ( | |
| 1345 | <> | |
| 1346 | <InterfaceFontRow preview={<PromptFontPreview />} /> | |
| 1347 | <CodeFontRow | |
| 1348 | title="Monospace font" | |
| 1349 | description="Code blocks, diffs, file previews, and the terminal." | |
| 1350 | preview={ | |
| 1351 | <> | |
| 1352 | <CodeFontPreview /> | |
| 1353 | <TerminalFontPreview | |
| 1354 | family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode} | |
| 1355 | size={settings.fontSizeTerminal} | |
| 1109 | 1356 | /> |
| 1357 | </> | |
| 1358 | } | |
| 1359 | /> | |
| 1360 | </> | |
| 1361 | ); | |
| 1362 | } | |
| 1363 | ||
| 1364 | // Font smoothing only renders on macOS, so a search jump to it elsewhere | |
| 1365 | // must not flip the section - the target would never mount to be scrolled to. | |
| 1366 | const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([ | |
| 1367 | "prompt-font", | |
| 1368 | "terminal-font", | |
| 1369 | ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform) | |
| 1370 | ? ["font-smoothing"] | |
| 1371 | : []), | |
| 1372 | ]); | |
| 1373 | ||
| 1374 | const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; | |
| 1375 | ||
| 1376 | /** | |
| 1377 | * The two-font view by default - one sans, one monospace, each cascading to | |
| 1378 | * every surface it reaches - with an Advanced switch in the section header | |
| 1379 | * that reveals the per-surface override rows. The choice persists locally, | |
| 1380 | * and a settings-search jump to an override row flips Advanced on so the | |
| 1381 | * target exists to scroll to. | |
| 1382 | */ | |
| 1383 | function TypographySection() { | |
| 1384 | const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); | |
| 1385 | const searchTargetId = useSettingsSearchTargetId(); | |
| 1386 | // Flip Advanced on once per search jump so the hidden target can mount and | |
| 1387 | // scroll; tracking the handled id lets the user turn it back off without | |
| 1388 | // the still-set target immediately re-expanding the section. | |
| 1389 | const lastExpandedTargetRef = useRef<string | null>(null); | |
| 1390 | useEffect(() => { | |
| 1391 | if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; | |
| 1392 | if (lastExpandedTargetRef.current === searchTargetId) return; | |
| 1393 | lastExpandedTargetRef.current = searchTargetId; | |
| 1394 | setAdvanced(true); | |
| 1395 | }, [searchTargetId, setAdvanced]); | |
| 1396 | return ( | |
| 1397 | <SettingsSection | |
| 1398 | title="Typography" | |
| 1399 | headerAction={ | |
| 1400 | <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground"> | |
| 1401 | Advanced | |
| 1402 | <Switch | |
| 1403 | checked={advanced} | |
| 1404 | onCheckedChange={(checked) => setAdvanced(Boolean(checked))} | |
| 1405 | aria-label="Show advanced typography settings" | |
| 1406 | /> | |
| 1407 | </label> | |
| 1408 | } | |
| 1409 | > | |
| 1410 | {advanced ? <FontSettingsGroup /> : <SimpleFontRows />} | |
| 1411 | <WordWrapRow /> | |
| 1412 | </SettingsSection> | |
| 1413 | ); | |
| 1414 | } | |
| 1415 | ||
| 1416 | function FontFamilySettingsRow({ | |
| 1417 | id, | |
| 1418 | title, | |
| 1419 | description, | |
| 1420 | defaultFamily, | |
| 1421 | preview, | |
| 1422 | value, | |
| 1423 | onValueChange, | |
| 1424 | requireMonospace = false, | |
| 1425 | size, | |
| 1426 | }: { | |
| 1427 | id?: string; | |
| 1428 | title: string; | |
| 1429 | description: string; | |
| 1430 | /** What an unset preference renders as, e.g. "Menlo". */ | |
| 1431 | defaultFamily: string; | |
| 1432 | preview?: ReactNode; | |
| 1433 | value: string; | |
| 1434 | onValueChange: (value: string) => void; | |
| 1435 | requireMonospace?: boolean; | |
| 1436 | size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void }; | |
| 1437 | }) { | |
| 1438 | const trimmed = value.trim(); | |
| 1439 | // The fallback input edits a draft; the preference only commits once typing | |
| 1440 | // pauses and the text probes as an available font (or is an explicit | |
| 1441 | // clear), so the current font holds and nothing reflows mid-word. | |
| 1442 | const [draft, setDraft] = useState(value); | |
| 1443 | const [draftSettled, setDraftSettled] = useState(true); | |
| 1444 | const commitTimerRef = useRef<number | null>(null); | |
| 1445 | const lastValueRef = useRef(value); | |
| 1446 | if (lastValueRef.current !== value) { | |
| 1447 | // The committed value changed externally (hydration, reset, picker | |
| 1448 | // selection); adopt it and drop any pending commit of a stale draft. | |
| 1449 | lastValueRef.current = value; | |
| 1450 | if (commitTimerRef.current !== null) { | |
| 1451 | window.clearTimeout(commitTimerRef.current); | |
| 1452 | commitTimerRef.current = null; | |
| 1453 | } | |
| 1454 | setDraft(value); | |
| 1455 | setDraftSettled(true); | |
| 1456 | } | |
| 1457 | useEffect( | |
| 1458 | () => () => { | |
| 1459 | if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current); | |
| 1460 | }, | |
| 1461 | [], | |
| 1462 | ); | |
| 1463 | const acceptsFamily = (candidate: string) => | |
| 1464 | isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); | |
| 1465 | const commitDraft = (next: string) => { | |
| 1466 | setDraftSettled(true); | |
| 1467 | // A rejected name stays in the field, flagged: the terminal would silently | |
| 1468 | // fall back to its default, so the row must not claim it took the value. | |
| 1469 | if (next.trim().length === 0 || acceptsFamily(next)) { | |
| 1470 | onValueChange(next); | |
| 1471 | } | |
| 1472 | }; | |
| 1473 | const flushDraft = () => { | |
| 1474 | if (commitTimerRef.current === null) return; | |
| 1475 | window.clearTimeout(commitTimerRef.current); | |
| 1476 | commitTimerRef.current = null; | |
| 1477 | commitDraft(draft); | |
| 1478 | }; | |
| 1479 | const draftTrimmed = draft.trim(); | |
| 1480 | // Flag an unknown name only once typing pauses, and never for an empty | |
| 1481 | // field - that is the starting state, not a rejected entry. | |
| 1482 | const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; | |
| 1483 | const resetAction = | |
| 1484 | trimmed.length > 0 ? ( | |
| 1485 | <SettingResetButton | |
| 1486 | label={`${title.toLowerCase()} family`} | |
| 1487 | onClick={() => onValueChange("")} | |
| 1488 | /> | |
| 1489 | ) : null; | |
| 1490 | const fontEnumeration = useFontEnumeration(); | |
| 1491 | // Everyone starts on the plain input; focusing it is the user gesture that | |
| 1492 | // runs font discovery. Where the engine can enumerate, the control then | |
| 1493 | // upgrades to the picker - popped open when the swap happens under focus, | |
| 1494 | // so the interaction continues without a second click. | |
| 1495 | const inputFocusedRef = useRef(false); | |
| 1496 | const familyControl = | |
| 1497 | fontEnumeration.status === "granted" ? ( | |
| 1498 | <FontFamilyPicker | |
| 1499 | ariaLabel={`${title} family`} | |
| 1500 | defaultFamily={defaultFamily} | |
| 1501 | selectedFamily={trimmed} | |
| 1502 | requireMonospace={requireMonospace} | |
| 1503 | initialOpen={inputFocusedRef.current} | |
| 1504 | onSelect={onValueChange} | |
| 1505 | /> | |
| 1506 | ) : ( | |
| 1507 | <Input | |
| 1508 | aria-label={`${title} family`} | |
| 1509 | aria-invalid={draftPending || undefined} | |
| 1510 | autoCapitalize="off" | |
| 1511 | autoComplete="off" | |
| 1512 | className="min-w-0 flex-1" | |
| 1513 | maxLength={200} | |
| 1514 | onFocus={() => { | |
| 1515 | inputFocusedRef.current = true; | |
| 1516 | discoverInstalledFonts(); | |
| 1517 | }} | |
| 1518 | onBlur={() => { | |
| 1519 | inputFocusedRef.current = false; | |
| 1520 | flushDraft(); | |
| 1521 | }} | |
| 1522 | onChange={(event) => { | |
| 1523 | const next = event.currentTarget.value; | |
| 1524 | setDraft(next); | |
| 1525 | setDraftSettled(false); | |
| 1526 | if (commitTimerRef.current !== null) { | |
| 1527 | window.clearTimeout(commitTimerRef.current); | |
| 1110 | 1528 | } |
| 1111 | /> | |
| 1112 | </SettingsSection> | |
| 1113 | </SettingsPageContainer> | |
| 1529 | commitTimerRef.current = window.setTimeout(() => { | |
| 1530 | commitTimerRef.current = null; | |
| 1531 | commitDraft(next); | |
| 1532 | }, 400); | |
| 1533 | }} | |
| 1534 | onKeyDown={(event) => { | |
| 1535 | if (event.key === "Enter") flushDraft(); | |
| 1536 | if (event.key === "Escape") { | |
| 1537 | // Discard uncommitted typing without closing the settings page, | |
| 1538 | // which is what an unhandled Escape does. | |
| 1539 | event.preventDefault(); | |
| 1540 | event.stopPropagation(); | |
| 1541 | if (commitTimerRef.current !== null) { | |
| 1542 | window.clearTimeout(commitTimerRef.current); | |
| 1543 | commitTimerRef.current = null; | |
| 1544 | } | |
| 1545 | setDraft(value); | |
| 1546 | setDraftSettled(true); | |
| 1547 | } | |
| 1548 | }} | |
| 1549 | placeholder={defaultFamily} | |
| 1550 | spellCheck={false} | |
| 1551 | value={draft} | |
| 1552 | /> | |
| 1553 | ); | |
| 1554 | const control = ( | |
| 1555 | <div className="flex w-full items-center gap-2 sm:w-auto"> | |
| 1556 | <div className="min-w-0 flex-1 sm:w-44 sm:flex-none">{familyControl}</div> | |
| 1557 | <Select | |
| 1558 | value={String(size.value)} | |
| 1559 | onValueChange={(next) => { | |
| 1560 | if (typeof next !== "string") return; | |
| 1561 | const parsed = Number(next); | |
| 1562 | if (Number.isInteger(parsed) && parsed >= size.min && parsed <= size.max) { | |
| 1563 | size.onChange(parsed); | |
| 1564 | } | |
| 1565 | }} | |
| 1566 | > | |
| 1567 | <SelectTrigger className="w-22 shrink-0" aria-label={size.label}> | |
| 1568 | <SelectValue>{size.value} px</SelectValue> | |
| 1569 | </SelectTrigger> | |
| 1570 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 1571 | {Array.from({ length: size.max - size.min + 1 }, (_, index) => size.min + index).map( | |
| 1572 | (px) => ( | |
| 1573 | <SelectItem hideIndicator key={px} value={String(px)}> | |
| 1574 | {px} px | |
| 1575 | </SelectItem> | |
| 1576 | ), | |
| 1577 | )} | |
| 1578 | </SelectPopup> | |
| 1579 | </Select> | |
| 1580 | </div> | |
| 1581 | ); | |
| 1582 | return ( | |
| 1583 | <SettingsRow | |
| 1584 | {...(id !== undefined ? { id } : {})} | |
| 1585 | title={title} | |
| 1586 | description={description} | |
| 1587 | resetAction={resetAction} | |
| 1588 | control={control} | |
| 1589 | > | |
| 1590 | {preview} | |
| 1591 | </SettingsRow> | |
| 1114 | 1592 | ); |
| 1115 | 1593 | } |
| 1116 | 1594 |
apps/web/src/components/settings/settingsLayout.tsx+5| ... | ... | @@ -62,6 +62,11 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void { |
| 62 | 62 | }); |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | /** The row id a settings-search jump is currently trying to reach, if any. */ | |
| 66 | export function useSettingsSearchTargetId(): string | null { | |
| 67 | return useContext(SettingsSearchTargetContext).targetId; | |
| 68 | } | |
| 69 | ||
| 65 | 70 | function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) { |
| 66 | 71 | const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext); |
| 67 | 72 | const isSearchTarget = id !== undefined && id === targetId; |
apps/web/src/components/settings/settingsSearch.ts+25| ... | ... | @@ -55,6 +55,31 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 55 | 55 | // The setting is stage-dependent, so its parent section is the stable destination. |
| 56 | 56 | targetId: "appearance", |
| 57 | 57 | }, |
| 58 | { | |
| 59 | id: "interface-font", | |
| 60 | title: "Interface font", | |
| 61 | to: "/settings/appearance", | |
| 62 | }, | |
| 63 | { | |
| 64 | id: "prompt-font", | |
| 65 | title: "Prompt font", | |
| 66 | to: "/settings/appearance", | |
| 67 | }, | |
| 68 | { | |
| 69 | id: "code-font", | |
| 70 | title: "Code font", | |
| 71 | to: "/settings/appearance", | |
| 72 | }, | |
| 73 | { | |
| 74 | id: "terminal-font", | |
| 75 | title: "Terminal font", | |
| 76 | to: "/settings/appearance", | |
| 77 | }, | |
| 78 | { | |
| 79 | id: "font-smoothing", | |
| 80 | title: "Font smoothing", | |
| 81 | to: "/settings/appearance", | |
| 82 | }, | |
| 58 | 83 | { |
| 59 | 84 | id: "word-wrap", |
| 60 | 85 | title: "Word wrap", |
apps/web/src/index.css+77-41| ... | ... | @@ -130,6 +130,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 130 | 130 | ); |
| 131 | 131 | } |
| 132 | 132 | |
| 133 | /* The font tokens are declared outside the inline theme so utilities reference | |
| 134 | the variables and Settings -> Appearance can override them at runtime. The | |
| 135 | default stacks are mirrored in `appearanceFonts.ts`. */ | |
| 136 | @theme { | |
| 137 | --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | |
| 138 | --font-mono: | |
| 139 | ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; | |
| 140 | } | |
| 141 | ||
| 133 | 142 | @theme inline { |
| 134 | 143 | --color-zinc-25: oklch(99.2% 0 0); |
| 135 | 144 | --animate-skeleton: skeleton 2s -1s infinite linear; |
| ... | ... | @@ -138,11 +147,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 138 | 147 | --animate-status-pulse: status-pulse 2s infinite; |
| 139 | 148 | --animate-status-ping: status-ping 2s infinite; |
| 140 | 149 | --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; |
| 141 | --font-sans: | |
| 142 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, | |
| 143 | sans-serif; | |
| 144 | --font-mono: | |
| 145 | "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; | |
| 146 | 150 | --color-warning-foreground: var(--warning-foreground); |
| 147 | 151 | --color-warning: var(--warning); |
| 148 | 152 | --color-success-foreground: var(--success-foreground); |
| ... | ... | @@ -679,11 +683,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 679 | 683 | background: color-mix(in srgb, var(--background) 64%, transparent); |
| 680 | 684 | } |
| 681 | 685 | |
| 682 | .glass-opacity-slider { | |
| 683 | --glass-slider-progress: 0%; | |
| 684 | --glass-slider-fill-offset: 0.5rem; | |
| 685 | --glass-slider-fill-position: calc( | |
| 686 | var(--glass-slider-progress) + var(--glass-slider-fill-offset) | |
| 686 | .settings-slider { | |
| 687 | --settings-slider-progress: 0%; | |
| 688 | --settings-slider-fill-offset: 0.5rem; | |
| 689 | --settings-slider-fill-position: calc( | |
| 690 | var(--settings-slider-progress) + var(--settings-slider-fill-offset) | |
| 687 | 691 | ); |
| 688 | 692 | appearance: none; |
| 689 | 693 | height: 1.5rem; |
| ... | ... | @@ -691,32 +695,32 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 691 | 695 | cursor: pointer; |
| 692 | 696 | } |
| 693 | 697 | |
| 694 | .glass-opacity-slider::-webkit-slider-runnable-track { | |
| 698 | .settings-slider::-webkit-slider-runnable-track { | |
| 695 | 699 | height: 0.375rem; |
| 696 | 700 | border-radius: 9999px; |
| 697 | 701 | background: linear-gradient( |
| 698 | 702 | to right, |
| 699 | var(--primary) 0 var(--glass-slider-fill-position), | |
| 700 | color-mix(in srgb, var(--muted-foreground) 22%, transparent) var(--glass-slider-fill-position) | |
| 701 | 100% | |
| 703 | var(--primary) 0 var(--settings-slider-fill-position), | |
| 704 | color-mix(in srgb, var(--muted-foreground) 22%, transparent) | |
| 705 | var(--settings-slider-fill-position) 100% | |
| 702 | 706 | ); |
| 703 | 707 | box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); |
| 704 | 708 | } |
| 705 | 709 | |
| 706 | .glass-opacity-slider::-moz-range-track { | |
| 710 | .settings-slider::-moz-range-track { | |
| 707 | 711 | height: 0.375rem; |
| 708 | 712 | border-radius: 9999px; |
| 709 | 713 | background: color-mix(in srgb, var(--muted-foreground) 22%, transparent); |
| 710 | 714 | box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); |
| 711 | 715 | } |
| 712 | 716 | |
| 713 | .glass-opacity-slider::-moz-range-progress { | |
| 717 | .settings-slider::-moz-range-progress { | |
| 714 | 718 | height: 0.375rem; |
| 715 | 719 | border-radius: 9999px; |
| 716 | 720 | background: var(--primary); |
| 717 | 721 | } |
| 718 | 722 | |
| 719 | .glass-opacity-slider::-webkit-slider-thumb { | |
| 723 | .settings-slider::-webkit-slider-thumb { | |
| 720 | 724 | appearance: none; |
| 721 | 725 | width: 1rem; |
| 722 | 726 | height: 1rem; |
| ... | ... | @@ -730,7 +734,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 730 | 734 | box-shadow 120ms ease; |
| 731 | 735 | } |
| 732 | 736 | |
| 733 | .glass-opacity-slider::-moz-range-thumb { | |
| 737 | .settings-slider::-moz-range-thumb { | |
| 734 | 738 | width: 1rem; |
| 735 | 739 | height: 1rem; |
| 736 | 740 | border: 2px solid var(--primary); |
| ... | ... | @@ -742,54 +746,54 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 742 | 746 | box-shadow 120ms ease; |
| 743 | 747 | } |
| 744 | 748 | |
| 745 | .glass-opacity-slider:hover::-webkit-slider-thumb { | |
| 749 | .settings-slider:hover::-webkit-slider-thumb { | |
| 746 | 750 | transform: scale(1.08); |
| 747 | 751 | box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); |
| 748 | 752 | } |
| 749 | 753 | |
| 750 | .glass-opacity-slider:hover::-moz-range-thumb { | |
| 754 | .settings-slider:hover::-moz-range-thumb { | |
| 751 | 755 | transform: scale(1.08); |
| 752 | 756 | box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); |
| 753 | 757 | } |
| 754 | 758 | |
| 755 | .glass-opacity-slider:active::-webkit-slider-thumb { | |
| 759 | .settings-slider:active::-webkit-slider-thumb { | |
| 756 | 760 | transform: scale(0.94); |
| 757 | 761 | } |
| 758 | 762 | |
| 759 | .glass-opacity-slider:active::-moz-range-thumb { | |
| 763 | .settings-slider:active::-moz-range-thumb { | |
| 760 | 764 | transform: scale(0.94); |
| 761 | 765 | } |
| 762 | 766 | |
| 763 | .glass-opacity-slider:focus-visible { | |
| 767 | .settings-slider:focus-visible { | |
| 764 | 768 | outline: none; |
| 765 | 769 | } |
| 766 | 770 | |
| 767 | .glass-opacity-slider:focus-visible::-webkit-slider-thumb { | |
| 771 | .settings-slider:focus-visible::-webkit-slider-thumb { | |
| 768 | 772 | box-shadow: |
| 769 | 773 | 0 0 0 3px var(--background), |
| 770 | 774 | 0 0 0 5px var(--ring); |
| 771 | 775 | } |
| 772 | 776 | |
| 773 | .glass-opacity-slider:focus-visible::-moz-range-thumb { | |
| 777 | .settings-slider:focus-visible::-moz-range-thumb { | |
| 774 | 778 | box-shadow: |
| 775 | 779 | 0 0 0 3px var(--background), |
| 776 | 780 | 0 0 0 5px var(--ring); |
| 777 | 781 | } |
| 778 | 782 | |
| 779 | 783 | @media (forced-colors: active) { |
| 780 | .glass-opacity-slider { | |
| 784 | .settings-slider { | |
| 781 | 785 | appearance: auto; |
| 782 | 786 | accent-color: Highlight; |
| 783 | 787 | } |
| 784 | 788 | |
| 785 | .glass-opacity-slider::-webkit-slider-runnable-track, | |
| 786 | .glass-opacity-slider::-webkit-slider-thumb { | |
| 789 | .settings-slider::-webkit-slider-runnable-track, | |
| 790 | .settings-slider::-webkit-slider-thumb { | |
| 787 | 791 | all: revert; |
| 788 | 792 | } |
| 789 | 793 | |
| 790 | .glass-opacity-slider::-moz-range-track, | |
| 791 | .glass-opacity-slider::-moz-range-progress, | |
| 792 | .glass-opacity-slider::-moz-range-thumb { | |
| 794 | .settings-slider::-moz-range-track, | |
| 795 | .settings-slider::-moz-range-progress, | |
| 796 | .settings-slider::-moz-range-thumb { | |
| 793 | 797 | all: revert; |
| 794 | 798 | } |
| 795 | 799 | } |
| ... | ... | @@ -997,14 +1001,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 997 | 1001 | } |
| 998 | 1002 | |
| 999 | 1003 | body { |
| 1000 | font-family: | |
| 1001 | "DM Sans Variable", | |
| 1002 | "DM Sans", | |
| 1003 | -apple-system, | |
| 1004 | BlinkMacSystemFont, | |
| 1005 | "Segoe UI", | |
| 1006 | system-ui, | |
| 1007 | sans-serif; | |
| 1004 | /* Reference the theme token (not a literal stack) so the Settings -> | |
| 1005 | Appearance runtime override of --font-sans reaches all interface text. */ | |
| 1006 | font-family: var(--font-sans); | |
| 1008 | 1007 | margin: 0; |
| 1009 | 1008 | padding: 0; |
| 1010 | 1009 | } |
| ... | ... | @@ -1055,8 +1054,27 @@ body { |
| 1055 | 1054 | |
| 1056 | 1055 | pre, |
| 1057 | 1056 | code { |
| 1058 | font-family: | |
| 1059 | "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; | |
| 1057 | font-family: var(--font-mono); | |
| 1058 | } | |
| 1059 | ||
| 1060 | /* Code blocks in chat carry the size preference. Scoped to chat markdown | |
| 1061 | rather than every pre: a global rule would beat text-size utilities and | |
| 1062 | inherited sizes on unrelated pre surfaces (terminal previews, approvals). | |
| 1063 | Inline code stays relative to its sentence so it never towers over prose; | |
| 1064 | diffs and file previews take the size through --diffs-font-size. */ | |
| 1065 | .chat-markdown .chat-markdown-shiki .shiki, | |
| 1066 | .chat-markdown pre code { | |
| 1067 | font-size: var(--font-size-code, inherit); | |
| 1068 | } | |
| 1069 | ||
| 1070 | /* @pierre/diffs surfaces (diffs, file previews, annotatable code, search | |
| 1071 | lines) render inside shadow roots but consult these hooks with their own | |
| 1072 | literal stacks as fallback. Custom properties inherit across the shadow | |
| 1073 | boundary, so defining them once here routes every code surface through the | |
| 1074 | appearance font tokens. */ | |
| 1075 | :root { | |
| 1076 | --diffs-font-family: var(--font-mono); | |
| 1077 | --diffs-header-font-family: var(--font-sans); | |
| 1060 | 1078 | } |
| 1061 | 1079 | |
| 1062 | 1080 | /* Window drag region (frameless titlebar) */ |
| ... | ... | @@ -1090,6 +1108,24 @@ code { |
| 1090 | 1108 | background: var(--app-scrollbar-thumb-hover); |
| 1091 | 1109 | } |
| 1092 | 1110 | |
| 1111 | /* Settings -> Appearance can point the composer at its own face (for example a | |
| 1112 | mono font); default follows the sans stack. Applied on the surface wrapper so | |
| 1113 | the editor and its placeholder inherit together. */ | |
| 1114 | .composer-editor-surface { | |
| 1115 | font-family: var(--font-composer, var(--font-sans)); | |
| 1116 | font-size: var(--font-size-prompt, 0.875rem); | |
| 1117 | } | |
| 1118 | ||
| 1119 | /* Touch browsers zoom the page when a focused field is under 16px, so keep | |
| 1120 | the floor there regardless of the preference. Gated on a coarse pointer: | |
| 1121 | the zoom quirk does not exist on desktop, where a narrow window must not | |
| 1122 | silently override a smaller chosen prompt size. */ | |
| 1123 | @media (max-width: 39.999rem) and (pointer: coarse) { | |
| 1124 | .composer-editor-surface { | |
| 1125 | font-size: max(var(--font-size-prompt, 1rem), 16px); | |
| 1126 | } | |
| 1127 | } | |
| 1128 | ||
| 1093 | 1129 | .t3-ghostty-canvas { |
| 1094 | 1130 | cursor: text; |
| 1095 | 1131 | } |
apps/web/src/main.tsx-3| ... | ... | @@ -5,9 +5,6 @@ import { passkeys } from "@clerk/electron/passkeys"; |
| 5 | 5 | import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; |
| 6 | 6 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; |
| 7 | 7 | |
| 8 | import "@fontsource-variable/dm-sans/index.css"; | |
| 9 | import "@fontsource/jetbrains-mono/400.css"; | |
| 10 | import "@fontsource/jetbrains-mono/500.css"; | |
| 11 | 8 | import "./index.css"; |
| 12 | 9 | |
| 13 | 10 | import { isElectron } from "./env"; |
apps/web/src/routes/__root.tsx+34| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | toastManager, |
| 28 | 28 | } from "../components/ui/toast"; |
| 29 | 29 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 30 | import { applyAppearanceFontVariables } from "~/appearanceFonts"; | |
| 30 | 31 | import { useClientSettings } from "../hooks/useSettings"; |
| 31 | 32 | import { |
| 32 | 33 | deriveLogicalProjectKeyFromSettings, |
| ... | ... | @@ -128,6 +129,7 @@ function RootRouteView() { |
| 128 | 129 | <AnchoredToastProvider> |
| 129 | 130 | <DocumentTitleSync /> |
| 130 | 131 | <GlassAppearanceSync /> |
| 132 | <FontAppearanceSync /> | |
| 131 | 133 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 132 | 134 | <RelayClientInstallDialog /> |
| 133 | 135 | <ConnectOnboardingDialog /> |
| ... | ... | @@ -152,6 +154,38 @@ function GlassAppearanceSync() { |
| 152 | 154 | return null; |
| 153 | 155 | } |
| 154 | 156 | |
| 157 | function FontAppearanceSync() { | |
| 158 | const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); | |
| 159 | const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); | |
| 160 | const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer); | |
| 161 | const fontSizeInterface = useClientSettings((settings) => settings.fontSizeInterface); | |
| 162 | const fontSizePrompt = useClientSettings((settings) => settings.fontSizePrompt); | |
| 163 | const fontSizeCode = useClientSettings((settings) => settings.fontSizeCode); | |
| 164 | const fontSmoothing = useClientSettings((settings) => settings.fontSmoothing); | |
| 165 | ||
| 166 | useEffect(() => { | |
| 167 | applyAppearanceFontVariables(document.documentElement, { | |
| 168 | sans: fontFamilySans, | |
| 169 | code: fontFamilyCode, | |
| 170 | composer: fontFamilyComposer, | |
| 171 | sizeInterface: fontSizeInterface, | |
| 172 | sizePrompt: fontSizePrompt, | |
| 173 | sizeCode: fontSizeCode, | |
| 174 | smoothing: fontSmoothing, | |
| 175 | }); | |
| 176 | }, [ | |
| 177 | fontFamilyCode, | |
| 178 | fontFamilyComposer, | |
| 179 | fontFamilySans, | |
| 180 | fontSizeCode, | |
| 181 | fontSizeInterface, | |
| 182 | fontSizePrompt, | |
| 183 | fontSmoothing, | |
| 184 | ]); | |
| 185 | ||
| 186 | return null; | |
| 187 | } | |
| 188 | ||
| 155 | 189 | function DocumentTitleSync() { |
| 156 | 190 | const primaryServerVersion = |
| 157 | 191 | useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; |
apps/web/src/terminal/ghostty/surface.test.ts+36| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | terminalLinkAtPosition, |
| 20 | 20 | terminalContentOriginY, |
| 21 | 21 | terminalFontFamily, |
| 22 | fittedTerminalFontSize, | |
| 22 | 23 | terminalFontSize, |
| 23 | 24 | terminalWheelArrowData, |
| 24 | 25 | terminalWheelDeltaRows, |
| ... | ... | @@ -256,6 +257,41 @@ describe("terminal font resolution", () => { |
| 256 | 257 | expect(custom.endsWith("monospace")).toBe(true); |
| 257 | 258 | }); |
| 258 | 259 | |
| 260 | it("ignores proportional families the cell grid cannot lay out", () => { | |
| 261 | // jsdom has no canvas metrics, so the probe answers "monospace" and the | |
| 262 | // family is kept; the guard is exercised in the browser instead. Assert the | |
| 263 | // shape stays intact so a rejected face still yields a usable stack. | |
| 264 | const stack = terminalFontFamily("Helvetica Neue"); | |
| 265 | expect(stack.endsWith("monospace")).toBe(true); | |
| 266 | }); | |
| 267 | ||
| 268 | it("quotes families the canvas font shorthand would otherwise reject", () => { | |
| 269 | expect(terminalFontFamily("3270 Nerd Font").startsWith('"3270 Nerd Font", ')).toBe(true); | |
| 270 | expect(terminalFontFamily("M+ 1m").startsWith('"M+ 1m", ')).toBe(true); | |
| 271 | expect(terminalFontFamily("Cascadia Code, Menlo").startsWith('"Cascadia Code", Menlo, ')).toBe( | |
| 272 | true, | |
| 273 | ); | |
| 274 | expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); | |
| 275 | }); | |
| 276 | ||
| 277 | it("slides the rendered size down until a full-width grid fits the canvas", () => { | |
| 278 | // SF Mono-like advance: 0.6em per cell. | |
| 279 | const cellWidthAt = (size: number) => size * 0.6; | |
| 280 | // A wide drawer keeps the preference untouched. | |
| 281 | expect(fittedTerminalFontSize(cellWidthAt, 20, 1140)).toBe(20); | |
| 282 | // A split pane at the same preference shrinks until 80 columns fit. | |
| 283 | const fitted = fittedTerminalFontSize(cellWidthAt, 20, 570); | |
| 284 | expect(fitted).toBeLessThan(20); | |
| 285 | expect(Math.floor((570 - 8) / cellWidthAt(fitted))).toBeGreaterThanOrEqual(80); | |
| 286 | // A tiny pane stops at the legibility floor instead of vanishing. | |
| 287 | expect(fittedTerminalFontSize(cellWidthAt, 20, 220)).toBe(8); | |
| 288 | // A preference below the floor is honored as-is. | |
| 289 | expect(fittedTerminalFontSize(cellWidthAt, 6, 220)).toBe(6); | |
| 290 | // Unmeasured layouts leave the preference alone. | |
| 291 | expect(fittedTerminalFontSize(cellWidthAt, 14, 0)).toBe(14); | |
| 292 | expect(fittedTerminalFontSize(() => 0, 14, 600)).toBe(14); | |
| 293 | }); | |
| 294 | ||
| 259 | 295 | it("clamps requested font sizes to the supported range", () => { |
| 260 | 296 | expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); |
| 261 | 297 | expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); |
apps/web/src/terminal/ghostty/surface.ts+82-5| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | type GhosttyCellMetrics, |
| 14 | 14 | } from "./renderer"; |
| 15 | 15 | import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; |
| 16 | import { isMonospaceFamily } from "../../appearanceFonts"; | |
| 16 | 17 | |
| 17 | 18 | export const DEFAULT_TERMINAL_FONT_SIZE = 12; |
| 18 | 19 | const MIN_TERMINAL_FONT_SIZE = 6; |
| ... | ... | @@ -25,10 +26,11 @@ const TERMINAL_GLYPH_FALLBACKS = |
| 25 | 26 | '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + |
| 26 | 27 | '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + |
| 27 | 28 | '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; |
| 28 | // SF Mono where the platform has it (macOS), otherwise the bundled JetBrains | |
| 29 | // Mono webfont, so the default rendering is identical everywhere else. | |
| 29 | // The platform's own monospace faces; concrete names only, because an | |
| 30 | // unknown keyword (like ui-monospace) makes canvas font shorthand parsing | |
| 31 | // reject the whole string. | |
| 30 | 32 | export const DEFAULT_TERMINAL_FONT_FAMILY = |
| 31 | '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; | |
| 33 | '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; | |
| 32 | 34 | const CONTENT_PADDING = 4; |
| 33 | 35 | const MIN_SCROLLBAR_THUMB_HEIGHT = 18; |
| 34 | 36 | /** Half a blink cycle: the visible and hidden phases are equally long. */ |
| ... | ... | @@ -61,13 +63,69 @@ function ensureTerminalSymbolsFont(): Promise<void> { |
| 61 | 63 | return symbolsFontLoad; |
| 62 | 64 | } |
| 63 | 65 | |
| 66 | function quoteTerminalFontFamilies(list: string): string { | |
| 67 | return list | |
| 68 | .split(",") | |
| 69 | .map((name) => { | |
| 70 | const bare = name.trim(); | |
| 71 | if (bare.length === 0) return ""; | |
| 72 | if (/^(['"]).*\1$/.test(bare)) return bare; | |
| 73 | if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; | |
| 74 | return `"${bare.replaceAll('"', "")}"`; | |
| 75 | }) | |
| 76 | .filter((name) => name.length > 0) | |
| 77 | .join(", "); | |
| 78 | } | |
| 79 | ||
| 64 | 80 | export function terminalFontFamily(family?: string): string { |
| 65 | const custom = family?.trim(); | |
| 66 | if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY; | |
| 81 | // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes | |
| 82 | // the whole canvas font string invalid and the assignment silently no-ops. | |
| 83 | const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); | |
| 84 | if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; | |
| 85 | // The grid places the cursor and selection on one cell advance, so a | |
| 86 | // proportional face would draw its text narrower than its own cells. Refuse | |
| 87 | // it here rather than render a ragged grid with a stranded cursor. | |
| 88 | if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; | |
| 67 | 89 | // A custom face keeps the glyph fallbacks so prompt symbols stay covered. |
| 68 | 90 | return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; |
| 69 | 91 | } |
| 70 | 92 | |
| 93 | /** | |
| 94 | * Grids narrower than a classic 80-column terminal wrap command output hard, | |
| 95 | * so the rendered font size follows the canvas width: the preference is the | |
| 96 | * ceiling, and the size slides down (to a legibility floor) until a full-width | |
| 97 | * grid fits. A widening pane slides it back up toward the preference. | |
| 98 | */ | |
| 99 | const MIN_TERMINAL_FIT_COLUMNS = 80; | |
| 100 | const MIN_TERMINAL_FIT_FONT_SIZE = 8; | |
| 101 | ||
| 102 | export function fittedTerminalFontSize( | |
| 103 | cellWidthAt: (size: number) => number, | |
| 104 | requested: number, | |
| 105 | mountWidth: number, | |
| 106 | ): number { | |
| 107 | const available = mountWidth - CONTENT_PADDING * 2; | |
| 108 | if (available <= 0) return requested; | |
| 109 | const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE); | |
| 110 | const fits = (cellWidth: number) => | |
| 111 | cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS; | |
| 112 | let cellWidth = cellWidthAt(requested); | |
| 113 | if (cellWidth <= 0 || fits(cellWidth)) return requested; | |
| 114 | // The advance scales linearly with size for monospace faces: jump close to | |
| 115 | // the fitting size, then settle the remaining rounding one step at a time. | |
| 116 | const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS; | |
| 117 | let size = Math.max( | |
| 118 | floor, | |
| 119 | Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)), | |
| 120 | ); | |
| 121 | while (size > floor) { | |
| 122 | cellWidth = cellWidthAt(size); | |
| 123 | if (cellWidth <= 0 || fits(cellWidth)) break; | |
| 124 | size -= 1; | |
| 125 | } | |
| 126 | return size; | |
| 127 | } | |
| 128 | ||
| 71 | 129 | export function terminalFontSize(size?: number): number { |
| 72 | 130 | if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; |
| 73 | 131 | return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); |
| ... | ... | @@ -350,6 +408,7 @@ export class GhosttyTerminalSurface { |
| 350 | 408 | private metrics: GhosttyCellMetrics; |
| 351 | 409 | private fontFamily: string; |
| 352 | 410 | private fontSize: number; |
| 411 | private requestedFontSize: number; | |
| 353 | 412 | private fontEpoch = 0; |
| 354 | 413 | private readonly resizeObserver: ResizeObserver; |
| 355 | 414 | private readonly scrollbarThumb: HTMLDivElement; |
| ... | ... | @@ -425,6 +484,7 @@ export class GhosttyTerminalSurface { |
| 425 | 484 | this.theme = options.theme; |
| 426 | 485 | this.fontFamily = terminalFontFamily(options.font?.family); |
| 427 | 486 | this.fontSize = terminalFontSize(options.font?.size); |
| 487 | this.requestedFontSize = this.fontSize; | |
| 428 | 488 | this.resizeObserver = new ResizeObserver(() => this.fit()); |
| 429 | 489 | this.installEvents(); |
| 430 | 490 | this.watchDevicePixelRatio(); |
| ... | ... | @@ -544,6 +604,7 @@ export class GhosttyTerminalSurface { |
| 544 | 604 | } |
| 545 | 605 | if (this.disposed || epoch !== this.fontEpoch) return; |
| 546 | 606 | this.fontFamily = fontFamily; |
| 607 | this.requestedFontSize = fontSize; | |
| 547 | 608 | this.fontSize = fontSize; |
| 548 | 609 | this.applyFontMetrics(); |
| 549 | 610 | } |
| ... | ... | @@ -588,6 +649,22 @@ export class GhosttyTerminalSurface { |
| 588 | 649 | const width = this.mount.clientWidth; |
| 589 | 650 | const height = this.mount.clientHeight; |
| 590 | 651 | if (width <= 0 || height <= 0) return false; |
| 652 | const fitted = fittedTerminalFontSize( | |
| 653 | (size) => measureGhosttyCell(this.context, size, this.fontFamily).width, | |
| 654 | this.requestedFontSize, | |
| 655 | width, | |
| 656 | ); | |
| 657 | if (fitted !== this.fontSize) { | |
| 658 | this.fontSize = fitted; | |
| 659 | this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); | |
| 660 | // The grid-change branch below resizes the core, but only when the | |
| 661 | // column count moved; the cell geometry always did, so sync it here. | |
| 662 | this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); | |
| 663 | this.inputLeft = -1; | |
| 664 | this.inputTop = -1; | |
| 665 | this.forceFullRender = true; | |
| 666 | this.scrollbarDirty = true; | |
| 667 | } | |
| 591 | 668 | const ratio = window.devicePixelRatio || 1; |
| 592 | 669 | const pixelWidth = Math.max(1, Math.round(width * ratio)); |
| 593 | 670 | const pixelHeight = Math.max(1, Math.round(height * ratio)); |
packages/contracts/src/settings.ts+72| ... | ... | @@ -62,10 +62,54 @@ export const GlassOpacity = Schema.Int.check( |
| 62 | 62 | ); |
| 63 | 63 | export type GlassOpacity = typeof GlassOpacity.Type; |
| 64 | 64 | export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; |
| 65 | /** | |
| 66 | * Font size preferences, in CSS pixels. The ranges are deliberately narrow: | |
| 67 | * the interface size scales every rem-based dimension in the app, so the | |
| 68 | * bounds keep layouts intact rather than offering unusable extremes. | |
| 69 | */ | |
| 70 | export const MIN_INTERFACE_FONT_SIZE = 12; | |
| 71 | export const MAX_INTERFACE_FONT_SIZE = 20; | |
| 72 | export const InterfaceFontSize = Schema.Int.check( | |
| 73 | Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }), | |
| 74 | ); | |
| 75 | export type InterfaceFontSize = typeof InterfaceFontSize.Type; | |
| 76 | export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; | |
| 77 | ||
| 78 | export const MIN_PROMPT_FONT_SIZE = 12; | |
| 79 | export const MAX_PROMPT_FONT_SIZE = 20; | |
| 80 | export const PromptFontSize = Schema.Int.check( | |
| 81 | Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }), | |
| 82 | ); | |
| 83 | export type PromptFontSize = typeof PromptFontSize.Type; | |
| 84 | export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14; | |
| 85 | ||
| 86 | export const MIN_CODE_FONT_SIZE = 10; | |
| 87 | export const MAX_CODE_FONT_SIZE = 18; | |
| 88 | export const CodeFontSize = Schema.Int.check( | |
| 89 | Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }), | |
| 90 | ); | |
| 91 | export type CodeFontSize = typeof CodeFontSize.Type; | |
| 92 | export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13; | |
| 93 | ||
| 94 | export const MIN_TERMINAL_FONT_SIZE = 8; | |
| 95 | export const MAX_TERMINAL_FONT_SIZE = 20; | |
| 96 | export const TerminalFontSize = Schema.Int.check( | |
| 97 | Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), | |
| 98 | ); | |
| 99 | export type TerminalFontSize = typeof TerminalFontSize.Type; | |
| 100 | export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; | |
| 101 | ||
| 65 | 102 | export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); |
| 66 | 103 | export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; |
| 67 | 104 | export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; |
| 68 | 105 | |
| 106 | /** | |
| 107 | * A user-chosen font family (a single name or a comma-separated list). Empty | |
| 108 | * means "use the app default"; clients compose their own fallback stacks. | |
| 109 | */ | |
| 110 | export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); | |
| 111 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; | |
| 112 | ||
| 69 | 113 | export const ClientSettingsSchema = Schema.Struct({ |
| 70 | 114 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 71 | 115 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| ... | ... | @@ -80,6 +124,25 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 80 | 124 | glassOpacity: GlassOpacity.pipe( |
| 81 | 125 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), |
| 82 | 126 | ), |
| 127 | fontSizeInterface: InterfaceFontSize.pipe( | |
| 128 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), | |
| 129 | ), | |
| 130 | fontSizePrompt: PromptFontSize.pipe( | |
| 131 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROMPT_FONT_SIZE)), | |
| 132 | ), | |
| 133 | fontSizeCode: CodeFontSize.pipe( | |
| 134 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_CODE_FONT_SIZE)), | |
| 135 | ), | |
| 136 | fontSizeTerminal: TerminalFontSize.pipe( | |
| 137 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_FONT_SIZE)), | |
| 138 | ), | |
| 139 | fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 140 | fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 141 | fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 142 | fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 143 | // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); | |
| 144 | // disabling restores the platform's heavier default. No effect off macOS. | |
| 145 | fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 83 | 146 | // Model favorites. Historically keyed by provider kind, now |
| 84 | 147 | // widened to `ProviderInstanceId` so users can favorite a specific model |
| 85 | 148 | // on a custom provider instance (e.g. "Codex Personal · gpt-5") without |
| ... | ... | @@ -690,6 +753,15 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 690 | 753 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 691 | 754 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 692 | 755 | glassOpacity: Schema.optionalKey(GlassOpacity), |
| 756 | fontSizeInterface: Schema.optionalKey(InterfaceFontSize), | |
| 757 | fontSizePrompt: Schema.optionalKey(PromptFontSize), | |
| 758 | fontSizeCode: Schema.optionalKey(CodeFontSize), | |
| 759 | fontSizeTerminal: Schema.optionalKey(TerminalFontSize), | |
| 760 | fontFamilyCode: Schema.optionalKey(FontFamilyPreference), | |
| 761 | fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), | |
| 762 | fontFamilySans: Schema.optionalKey(FontFamilyPreference), | |
| 763 | fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), | |
| 764 | fontSmoothing: Schema.optionalKey(Schema.Boolean), | |
| 693 | 765 | favorites: Schema.optionalKey( |
| 694 | 766 | Schema.Array( |
| 695 | 767 | Schema.Struct({ |
pnpm-lock.yaml-16| ... | ... | @@ -532,12 +532,6 @@ importers: |
| 532 | 532 | '@effect/atom-react': |
| 533 | 533 | specifier: 4.0.0-beta.103 |
| 534 | 534 | version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) |
| 535 | '@fontsource-variable/dm-sans': | |
| 536 | specifier: ^5.2.8 | |
| 537 | version: 5.2.8 | |
| 538 | '@fontsource/jetbrains-mono': | |
| 539 | specifier: ^5.2.8 | |
| 540 | version: 5.2.8 | |
| 541 | 535 | '@formkit/auto-animate': |
| 542 | 536 | specifier: ^0.9.0 |
| 543 | 537 | version: 0.9.0 |
| ... | ... | @@ -2707,12 +2701,6 @@ packages: |
| 2707 | 2701 | '@floating-ui/utils@0.2.11': |
| 2708 | 2702 | resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} |
| 2709 | 2703 | |
| 2710 | '@fontsource-variable/dm-sans@5.2.8': | |
| 2711 | resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==} | |
| 2712 | ||
| 2713 | '@fontsource/jetbrains-mono@5.2.8': | |
| 2714 | resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} | |
| 2715 | ||
| 2716 | 2704 | '@formkit/auto-animate@0.9.0': |
| 2717 | 2705 | resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} |
| 2718 | 2706 | |
| ... | ... | @@ -12791,10 +12779,6 @@ snapshots: |
| 12791 | 12779 | |
| 12792 | 12780 | '@floating-ui/utils@0.2.11': {} |
| 12793 | 12781 | |
| 12794 | '@fontsource-variable/dm-sans@5.2.8': {} | |
| 12795 | ||
| 12796 | '@fontsource/jetbrains-mono@5.2.8': {} | |
| 12797 | ||
| 12798 | 12782 | '@formkit/auto-animate@0.9.0': {} |
| 12799 | 12783 | |
| 12800 | 12784 | '@hono/node-server@1.19.14(hono@4.12.27)': |