diff --git a/apps/packages/theme/src/fonts.css b/apps/packages/theme/src/fonts.css index 6aab78338f..f55227635a 100644 --- a/apps/packages/theme/src/fonts.css +++ b/apps/packages/theme/src/fonts.css @@ -100,3 +100,108 @@ font-style: normal; font-display: swap; } + +/* Powerline separators and Nerd Font icons, as carried by text copied from a + styled terminal prompt (Oh-My-Posh, Starship), live in the Unicode Private + Use Area. PUA codepoints have no script or language association, so the + platform's font-matching cannot resolve them the way it resolves CJK or + emoji. They render as notdef boxes unless a covering font is named + explicitly. + + Separate local and bundled faces preserve per-character fallback. The first + installed source wins for the local face. If it lacks one PUA glyph, the + browser can continue to the bundled family instead of being trapped in one + `src` list. `unicode-range` confines both faces to the PUA, so every other + character still uses the UI typeface. Families mirror the presets in + `lib/terminal/terminal-font.ts`. + + Every entry is a full font name or a PostScript name. `local()` does not + match a family name alone. The aliases below come from the regular faces in + Nerd Fonts v3.5.0; both names are listed where the full and PostScript + forms differ. Powerlevel10k ships MesloLGS with full name "MesloLGS NF + Regular" and PostScript name "MesloLGS-NF-Regular", so both distribution + aliases are listed too. Only patched faces are listed because an unrelated + local PUA glyph could otherwise preempt the intended bundled glyph. */ +@font-face { + font-family: "NerdFontLocalGlyphs"; + src: + local("Symbols Nerd Font"), local("SymbolsNF"), local("MesloLGS Nerd Font Regular"), + local("MesloLGSNF-Regular"), local("MesloLGS NF Regular"), local("MesloLGS-NF-Regular"), + local("MesloLGM Nerd Font Regular"), local("MesloLGMNF-Regular"), + local("MesloLGL Nerd Font Regular"), local("MesloLGLNF-Regular"), + local("CaskaydiaCove NF Regular"), local("CaskaydiaCoveNF-Regular"), + local("CaskaydiaMono NF Regular"), local("CaskaydiaMonoNF-Regular"), + local("JetBrainsMono NF Regular"), local("JetBrainsMonoNF-Regular"), + local("FiraCode Nerd Font Reg"), local("FiraCodeNF-Reg"), local("Hack Nerd Font Regular"), + local("HackNF-Regular"), local("Iosevka NF"), local("IosevkaNF"), local("IosevkaTerm NF"), + local("IosevkaTermNF"), local("SauceCodePro NF"), local("SauceCodeProNF"), + local("UbuntuMono Nerd Font"), local("UbuntuMonoNF"), local("RobotoMono Nerd Font"), + local("RobotoMonoNF"), local("DejaVuSansM Nerd Font"), local("DejaVuSansMNF"), + local("3270 Nerd Font"), local("3270NF"), local("Agave Nerd Font Regular"), + local("AgaveNF-Regular"), local("AnonymicePro Nerd Font"), local("AnonymiceProNF"), + local("Arimo Nerd Font Regular"), local("ArimoNF-Regular"), + local("AurulentSansM Nerd Font Regular"), local("AurulentSansMNF-Regular"), + local("BigBlueTerm437 Nerd Font"), local("BigBlueTerm437NF"), local("BitstromWera Nerd Font"), + local("BitstromWeraNF"), local("BlexMono Nerd Font"), local("BlexMonoNF"), + local("CodeNewRoman Nerd Font"), local("CodeNewRomanNF"), local("ComicShannsMono Nerd Font"), + local("ComicShannsMonoNF"), local("CommitMono Nerd Font"), local("CommitMonoNF"), + local("Cousine Nerd Font Regular"), local("CousineNF-Regular"), + local("D2KodingLigature Nerd Font"), local("D2KodingLigatureNF"), + local("DaddyTimeMono Nerd Font"), local("DaddyTimeMonoNF"), local("DroidSansM Nerd Font"), + local("DroidSansMNF"), local("EnvyCodeR Nerd Font"), local("EnvyCodeRNF"), + local("FantasqueSansM Nerd Font Regular"), local("FantasqueSansMNF-Regular"), + local("FiraMono Nerd Font Regular"), local("FiraMonoNF-Regular"), local("GeistMono NF"), + local("GeistMonoNF"), local("GoMono Nerd Font"), local("GoMonoNF"), + local("GohuFont 11 Nerd Font"), local("GohuFont11NF"), local("GohuFont 14 Nerd Font"), + local("GohuFont14NF"), local("GohuFont uni11 Nerd Font"), local("GohuFontUni11NF"), + local("GohuFont uni14 Nerd Font"), local("GohuFontUni14NF"), local("Hasklug Nerd Font"), + local("HasklugNF"), local("HeavyData Nerd Font"), local("HeavyDataNF"), + local("Hurmit Nerd Font"), local("HurmitNF"), local("iMWritingMono Nerd Font"), + local("IMWritingMonoNF"), local("Inconsolata Nerd Font Regular"), + local("InconsolataNF-Regular"), local("InconsolataGo Nerd Font"), local("InconsolataGoNF"), + local("Inconsolata LGC Nerd Font"), local("InconsolataLGCNF"), local("IntoneMono NF"), + local("IntoneMonoNF"), local("Lekton Nerd Font"), local("LektonNF"), + local("LiterationMono Nerd Font"), local("LiterationMonoNF"), local("Lilex Nerd Font Regular"), + local("LilexNF-Regular"), local("MartianMono NF"), local("MartianMonoNF"), + local("MonaspiceAr NF"), local("MonaspiceArNF"), local("MonaspiceKr NF"), + local("MonaspiceKrNF"), local("MonaspiceNe NF"), local("MonaspiceNeNF"), + local("MonaspiceRn NF"), local("MonaspiceRnNF"), local("MonaspiceXe NF"), + local("MonaspiceXeNF"), local("Monofur Nerd Font"), local("MonofurNF"), + local("Monoid Nerd Font Regular"), local("MonoidNF-Regular"), + local("Mononoki Nerd Font Regular"), local("MononokiNF-Regular"), local("M+1 Nerd Font"), + local("M+1NF"), local("M+2 Nerd Font"), local("M+2NF"), local("M+1Code Nerd Font"), + local("M+1CodeNF"), local("M+CodeLat50 Nerd Font"), local("M+CodeLat50NF"), + local("M+CodeLat60 Nerd Font"), local("M+CodeLat60NF"), local("NotoMono NF"), + local("NotoMonoNF"), local("OpenDyslexicM Nerd Font"), local("OpenDyslexicMNF"), + local("Overpass Nerd Font Reg"), local("OverpassNF-Reg"), local("OverpassM Nerd Font Reg"), + local("OverpassMNF-Reg"), local("ProFont IIx Nerd Font"), local("ProFontIIxNF"), + local("ProFontWindows Nerd Font"), local("ProFontWindowsNF"), local("ProggyClean Nerd Font"), + local("ProggyCleanNF"), local("ProggyClean CE Nerd Font"), local("ProggyCleanCENF"), + local("ProggyCleanSZ Nerd Font"), local("ProggyCleanSZNF"), local("RecMonoCasual Nerd Font"), + local("RecMonoCasualNF"), local("RecMonoDuotone Nerd Font"), local("RecMonoDuotoneNF"), + local("RecMonoLinear Nerd Font"), local("RecMonoLinearNF"), local("RecMonoSmCasual Nerd Font"), + local("RecMonoSmCasualNF"), local("ShureTechMono Nerd Font"), local("ShureTechMonoNF"), + local("SpaceMono Nerd Font"), local("SpaceMonoNF"), local("Terminess Nerd Font"), + local("TerminessNF"), local("Tinos Nerd Font"), local("TinosNF"), local("UbuntuSans NF"), + local("UbuntuSansNF"), local("UbuntuSansMono NF"), local("UbuntuSansMonoNF"), + local("VictorMono NF Regular"), local("VictorMonoNF-Regular"), local("ZedMono NF"), + local("ZedMonoNF"); + unicode-range: U+E000-F8FF, U+F0000-FFFFD, U+100000-10FFFD; + /* Powerline separators fill a full terminal cell, so their ink is ~1.99x + the cap height of the proportional UI face at the same font size. + Measured: 133 vs 67 at 100px. Scale them to roughly 1em so they read as + inline glyphs rather than oversized blocks. Applies only to this face, + which serves the Private Use Area alone. */ + size-adjust: 75%; +} + +@font-face { + font-family: "NerdFontBundledGlyphs"; + src: url("/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2") format("woff2"); + /* Match the subset's actual cmap. A broad PUA range would download the file + for unsupported glyphs and still render a notdef box. */ + unicode-range: + U+E0A0-E0A3, U+E0B0-E0C8, U+E0CA, U+E0CC-E0D2, U+E0D4, U+E5FA-E6B7, U+E700-E8EF, U+F400-F533; + size-adjust: 75%; + font-display: swap; +} diff --git a/apps/web/app/globals-font-fallback.test.ts b/apps/web/app/globals-font-fallback.test.ts new file mode 100644 index 0000000000..bf1388cb4b --- /dev/null +++ b/apps/web/app/globals-font-fallback.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const LOCAL_GLYPH_FAMILY = "NerdFontLocalGlyphs"; +const BUNDLED_GLYPH_FAMILY = "NerdFontBundledGlyphs"; + +const css = readFileSync(join(__dirname, "globals.css"), "utf8"); +const themeCss = readFileSync(join(__dirname, "../../packages/theme/src/fonts.css"), "utf8"); + +// Slice to the rule's closing brace rather than a fixed character budget: a +// fixed window silently truncates as the rule grows, which already caused a +// descriptor to read as absent when it was present. +const fontFace = (family: string) => { + const faceNameStart = themeCss.indexOf(`font-family: "${family}"`); + const faceStart = themeCss.lastIndexOf("@font-face", faceNameStart); + return themeCss.slice(faceStart, themeCss.indexOf("}", faceNameStart) + 1); +}; + +const localFontFace = fontFace(LOCAL_GLYPH_FAMILY); +const bundledFontFace = fontFace(BUNDLED_GLYPH_FAMILY); + +/** The first real font stack declared for `name`, sliced to its terminating + * semicolon. + * + * Two traps here. A fixed character budget silently truncates as a stack + * grows, so the slice ends at the semicolon. And the first textual match is + * the Tailwind `@theme inline` alias (`--font-sans: var(--font-sans);`), + * which contains no families at all, so `var()`-only values are skipped. */ +const declaration = (name: string) => { + let from = 0; + for (;;) { + const start = css.indexOf(name, from); + if (start === -1) throw new Error(`no font stack found for ${name}`); + const end = css.indexOf(";", start) + 1; + const text = css.slice(start, end); + if (!/:\s*var\([^;]*\);$/.test(text.replace(/\s+/g, " "))) return text; + from = end; + } +}; + +describe("Nerd Font PUA glyph fallback", () => { + it("declares separate local and bundled font faces", () => { + expect(localFontFace).toContain(`font-family: "${LOCAL_GLYPH_FAMILY}"`); + expect(bundledFontFace).toContain(`font-family: "${BUNDLED_GLYPH_FAMILY}"`); + }); + + it("lets installed fonts cover all private use ranges", () => { + expect(localFontFace).toContain("U+E000-F8FF"); + expect(localFontFace).toContain("U+F0000-FFFFD"); + expect(localFontFace).toContain("U+100000-10FFFD"); + }); + + it("limits the bundled face to codepoints present in the subset", () => { + const ranges = [ + "U+E0A0-E0A3", + "U+E0B0-E0C8", + "U+E0CA", + "U+E0CC-E0D2", + "U+E0D4", + "U+E5FA-E6B7", + "U+E700-E8EF", + "U+F400-F533", + ]; + + for (const range of ranges) expect(bundledFontFace).toContain(range); + expect(bundledFontFace).not.toContain("U+E000-F8FF"); + expect(bundledFontFace).not.toContain("U+F0000-FFFFD"); + expect(bundledFontFace).not.toContain("U+100000-10FFFD"); + }); + + it("keeps local and bundled sources in different faces", () => { + expect(localFontFace).toContain("local("); + expect(localFontFace).not.toContain("url("); + expect(bundledFontFace).toContain("url("); + expect(bundledFontFace).not.toContain("local("); + }); + + it("serves the bundled subset from the app, not a third-party CDN", () => { + // A remote origin would leak that a user pasted terminal output, and add a + // runtime dependency on someone else's uptime. + const urls = [...bundledFontFace.matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]); + + expect(urls).toHaveLength(1); + expect(urls[0]).toBe("/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2"); + }); + + it("names every source by full font name, not by family name", () => { + // `local()` matches a full font name or PostScript name only. A family + // name silently fails and falls through to the next source, so an entry + // like local("MesloLGS") looks correct and renders nothing. Verified + // against Chromium: "MesloLGS" fails, "MesloLGS Nerd Font Regular" resolves. + const sources = [...localFontFace.matchAll(/local\("([^"]+)"\)/g)].map((m) => m[1]); + + expect(sources.length).toBeGreaterThan(1); + for (const source of sources) { + // Nerd Fonts v3.5.0 uses both full names ("Nerd Font") and PostScript + // aliases ("NF", "NFM", or "NFP"). A bare family name matches neither. + const isFullName = source.includes("Nerd Font"); + const isPostScriptName = /(?:NF|NFM|NFP)(?:[- ]|$)/.test(source); + expect(isFullName || isPostScriptName).toBe(true); + } + }); + + it("uses the upstream names for families whose aliases are not predictable", () => { + const sources = [...localFontFace.matchAll(/local\("([^"]+)"\)/g)].map((m) => m[1]); + const required = [ + "CaskaydiaCove NF Regular", + "CaskaydiaCoveNF-Regular", + "JetBrainsMono NF Regular", + "JetBrainsMonoNF-Regular", + "BigBlueTerm437 Nerd Font", + "BigBlueTerm437NF", + "D2KodingLigature Nerd Font", + "D2KodingLigatureNF", + ]; + + for (const source of required) expect(sources).toContain(source); + }); + + it("lists only Nerd Font patched faces, never an unpatched base font", () => { + // An unpatched base font adds no intended coverage. It can also claim an + // unrelated PUA glyph and preempt the bundled face for that character. + // Unpatched Cascadia Code shipped here once and covers 0 of the powerline, + // seti, devicon and octicon codepoints. + const sources = [...localFontFace.matchAll(/local\("([^"]+)"\)/g)].map((m) => m[1]); + + for (const source of sources) { + const isPatched = source.includes("Nerd Font") || /(?:NF|NFM|NFP)(?:[- ]|$)/.test(source); + expect(isPatched, `${source} is not a Nerd Font patched face`).toBe(true); + } + }); + + it("uses swap font-display so text is never invisible while the subset loads", () => { + // Without it the browser blocks rendering for up to 3s on a slow + // connection, hiding the surrounding text as well as the glyphs. + expect(bundledFontFace).toContain("font-display: swap"); + }); + + it("scales the glyphs down to sit inline with proportional text", () => { + // Powerline separators fill a full terminal cell, so unscaled they render + // at roughly twice the cap height of the UI face. Measured 1.99x before + // this descriptor, 1.43x after. + expect(localFontFace).toContain("size-adjust: 75%"); + expect(bundledFontFace).toContain("size-adjust: 75%"); + }); + + it("is reachable from every font stack in the stylesheet", () => { + // Asserting two named stacks was not enough: .markdown-body and + // .chat-message-list hardcode their own families instead of reading the + // variables, so rendered chat messages kept showing notdef boxes while the + // composer rendered glyphs. Scan every stack rather than a chosen few. + const outsideFontFace = css; + const stacks = [...outsideFontFace.matchAll(/(?:font-family|--font-[a-z-]+):\s*([^;]+);/g)] + .map((m) => ({ value: m[1].replace(/\s+/g, " ").trim() })) + .filter((s) => !s.value.startsWith("var(") && /[,]/.test(s.value)); + + expect(stacks.length).toBeGreaterThan(3); + for (const stack of stacks) { + expect(stack.value).toContain(LOCAL_GLYPH_FAMILY); + expect(stack.value).toContain(BUNDLED_GLYPH_FAMILY); + expect(stack.value.indexOf(LOCAL_GLYPH_FAMILY)).toBeLessThan( + stack.value.indexOf(BUNDLED_GLYPH_FAMILY), + ); + } + }); + + it("keeps the UI typeface ahead of the glyph fallback for ordinary text", () => { + const sans = declaration("--font-sans:"); + + expect(sans.indexOf("Figtree")).toBeLessThan(sans.indexOf(LOCAL_GLYPH_FAMILY)); + expect(sans.indexOf(LOCAL_GLYPH_FAMILY)).toBeLessThan(sans.indexOf(BUNDLED_GLYPH_FAMILY)); + }); +}); diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a0d9b932d3..24eea61d83 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -15,7 +15,8 @@ --color-foreground: var(--foreground); --font-sans: var(--font-sans); --font-mono: - "Monaspace Neon", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + "Monaspace Neon", "Geist Mono", "NerdFontLocalGlyphs", "NerdFontBundledGlyphs", ui-monospace, + SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-mono-size: 12px; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); @@ -72,11 +73,15 @@ :root { --app-status-bar-height: 0px; - --font-geist-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; - --font-sans: "Figtree", "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-geist-sans: + "Geist", "NerdFontLocalGlyphs", "NerdFontBundledGlyphs", ui-sans-serif, system-ui, + -apple-system, "Segoe UI", sans-serif; + --font-sans: + "Figtree", "Geist", "NerdFontLocalGlyphs", "NerdFontBundledGlyphs", ui-sans-serif, system-ui, + -apple-system, "Segoe UI", sans-serif; --font-geist-mono: - "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; + "Geist Mono", "NerdFontLocalGlyphs", "NerdFontBundledGlyphs", ui-monospace, "SFMono-Regular", + Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); @@ -919,8 +924,8 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state color: var(--foreground); overflow-wrap: anywhere; font-family: - -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji"; + -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, "NerdFontLocalGlyphs", + "NerdFontBundledGlyphs", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; } .markdown-body h1, @@ -1805,8 +1810,8 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state .chat-message-list { font-size: 13px; font-family: - -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji"; + -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, "NerdFontLocalGlyphs", + "NerdFontBundledGlyphs", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; } /* Shiki code block styling */ diff --git a/apps/web/public/fonts/nerd-symbols/LICENSE b/apps/web/public/fonts/nerd-symbols/LICENSE new file mode 100644 index 0000000000..06eb073d6b --- /dev/null +++ b/apps/web/public/fonts/nerd-symbols/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/web/public/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2 b/apps/web/public/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2 new file mode 100644 index 0000000000..580b9be5ac Binary files /dev/null and b/apps/web/public/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2 differ diff --git a/docs/plans/prompt-paste-nerd-font-glyphs/plan.md b/docs/plans/prompt-paste-nerd-font-glyphs/plan.md new file mode 100644 index 0000000000..6655be258d --- /dev/null +++ b/docs/plans/prompt-paste-nerd-font-glyphs/plan.md @@ -0,0 +1,148 @@ +--- +spec: docs/specs/prompt-paste-nerd-font-glyphs/spec.md +created: 2026-08-19 +status: done +--- + +# Implementation Plan: Render Nerd Font glyphs pasted from a styled terminal + +## Overview +Map Private Use Area codepoints through two `unicode-range` scoped font faces. +The first face uses an installed Nerd Font. The second face uses a small subset +that Kandev ships. Separate families preserve per-character fallback when a +selected local font lacks one glyph. CSS and one font asset change. No text is +modified, so the bytes that reach the backend and the agent are unchanged. + +## Confirmed root cause +The paste pipeline was never broken. TipTap, the WebSocket, SQLite and the +agent all carry the codepoints correctly; `U+E0B0` is transmitted as +`EE 82 B0`. The square is a render-time artifact. + +Platform font-matching resolves missing glyphs for characters carrying a script +or language association (CJK, emoji), which is why those render unnamed. +Private Use codepoints carry none by definition, so the OS nominates no font +and the browser draws notdef. An explicitly named covering font is the only +mechanism that works. + +`--font-sans` was `"Figtree", "Geist", ui-sans-serif, ...` with no `@font-face` +or `unicode-range` rule anywhere in the shared theme font catalog. Kandev's Nerd Font presets +in `lib/terminal/terminal-font.ts` were wired only to `terminalFontFamily` and +referenced by zero chat components. + +## Decision record: why not sanitize +An earlier implementation on this branch (reverted) stripped the Private Use +codepoints at paste time. It was rejected on review because **the agent may +need those characters**: a user pasting a prompt theme or powerline config is +often asking a question *about* those glyphs, and removing them deletes the +subject of the question. Review also found the stripping approach silently +discarded glyphs inside fenced code blocks, exactly where literal content must +survive. + +| Option | Font | Modifies sent text | User sees | Agent receives | +|---|---|---|---|---| +| 0 (before) | no | no | notdef box | unchanged | +| 1 (reverted) | no | yes, at paste | glyphs removed | modified | +| **2 (chosen)** | **yes** | **no** | **glyphs** | **unchanged** | +| 3 | yes | yes, at send | glyphs | modified | + +## Two defects found during implementation +Both were discovered by testing in a real browser rather than by reading the +CSS, and both are silent failures. + +1. **`local()` does not match family names.** The first implementation named + families (`local("MesloLGS")`) and rendered nothing at all. `local()` + matches a full font name or PostScript name only. Measured in Chromium + against an installed font: `local("MesloLGS")` produces notdef, + `local("MesloLGS Nerd Font Regular")` produces the glyph. An unmatched + source falls through silently, so the rule looks correct and does nothing. +2. **Ordering decided the winner arbitrarily.** Pasting the catalogue in + alphabetical order handed the match to `MesloLGL` over `MesloLGS` purely on + sort position. Harmless there (identical outlines) but wrong in general, + since glyph shapes differ between families and the closest match to the + user's terminal is preferred. + +## Approach +1. `NerdFontLocalGlyphs` in `apps/packages/theme/src/fonts.css` covers all + three PUA ranges. Its `src` lists the full and PostScript aliases for the 66 + Nerd Font families in the v3.5.0 release, ordered by intent. +2. `NerdFontBundledGlyphs` contains only the bundled URL. Its `unicode-range` + matches the subset's actual character map, so unsupported PUA codepoints do + not cause a download that cannot render them. +3. Bundled subset generated from the MIT-licensed `SymbolsNerdFont-Regular` + v3.5.0 with `fontTools`, restricted to the four icon sets a prompt uses, and + committed with its licence and a content-hashed filename at + `apps/web/public/fonts/nerd-symbols/`. +4. Both families carry `size-adjust: 75%`. Every font stack puts the local face + before the bundled face and puts both after the UI typefaces. +5. A guard test covers source separation, range accuracy, cache-safe naming, + and stack order. + +### Subset size curve (measured, woff2) + +| Ranges | Size | +|---|---| +| powerline only | 7 KB | +| + octicons | 33 KB | +| + seti/custom folders | 71 KB | +| **+ devicons (chosen)** | **236 KB** | +| + codicons | 280 KB | +| + Font Awesome | 472 KB | +| entire BMP PUA | 583 KB | + +Font Awesome doubles the payload for icons prompts rarely draw, so the cut is +after devicons. The bundled face declares its exact character map, so it is +fetched only when a codepoint that the file supports is rendered. + +## Tasks +- `task-01-pua-font-fallback.md` — shared theme font catalog, bundled subset, + guard test + +## Validation +From `apps/web`: +```bash +pnpm vitest run app/globals-font-fallback.test.ts +pnpm run typecheck +pnpm lint +``` + +Measured against the running app rather than asserted: +- `U+E0B0`, `U+F418`, `U+E5FF` render the installed glyph, not notdef. +- Winner is `MesloLGS Nerd Font Regular`, matching the terminal's own font. +- Bundled subset alone (no `local()`) renders powerline, git branch, folder and + devicon glyphs, proving the path for users with no Nerd Font. +- Separator drops from 1.99x to ~1.43x cap height under `size-adjust`. +- A/B of the stack with and without the fallback leaves Latin, accented, CJK, + emoji and box-drawing widths byte-identical. + +## Risks +- A Nerd Font outside the catalogue falls through to the bundled subset, so + common glyphs still render but family-specific ones may not. +- The first installed local source still wins. If it lacks one glyph, the + separate bundled family can render that character. The browser cannot try a + second installed source from the same `src` list for that character. +- Glyphs outside the subset (plane-15 Material Design, Font Awesome) remain + notdef for users with no Nerd Font. Widening requires regenerating the + subset from the pinned source archive and updating the CSS coverage and + recorded output hash. The measured size cost is tabulated above. +- CSS has no unit-testable logic, so the guard asserts the declaration's shape. + Rendering was verified by measuring glyph advance widths in a real browser. + +## Reproducible subset source + +The bundled file uses `NerdFontsSymbolsOnly.zip` from Nerd Fonts v3.5.0. The +source archive SHA-256 is +`49362450cd61b32c7d1dadbb98e82696d77cc215344636d25eabc8a82d6f8d7f`. + +After extracting `SymbolsNerdFont-Regular.ttf`, regenerate the file with: + +```bash +python -m fontTools.subset SymbolsNerdFont-Regular.ttf \ + --unicodes="U+E0A0-E0D4,U+F400-F533,U+E5FA-E6B7,U+E700-E8EF" \ + --flavor=woff2 \ + --output-file=apps/web/public/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2 +``` + +The committed WOFF2 SHA-256 is +`bca747e8daab16b628ebbc40bf67cd3ac961c143a40f794b9951c3f4e31e0618`. +The filename uses the first eight hash characters because static assets have a +one-year immutable cache policy. diff --git a/docs/plans/prompt-paste-nerd-font-glyphs/task-01-pua-font-fallback.md b/docs/plans/prompt-paste-nerd-font-glyphs/task-01-pua-font-fallback.md new file mode 100644 index 0000000000..f541514c65 --- /dev/null +++ b/docs/plans/prompt-paste-nerd-font-glyphs/task-01-pua-font-fallback.md @@ -0,0 +1,59 @@ +--- +id: "01-pua-font-fallback" +title: "Render Private Use Area codepoints via installed or bundled Nerd Font" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/prompt-paste-nerd-font-glyphs/spec.md" +parallelism: sequential +--- + +# Task 01: PUA font fallback + +## Context + +`apps/packages/theme/src/fonts.css` contained no `@font-face` and no `unicode-range` +rule. `--font-sans` began `"Figtree", "Geist", ...`, none of which cover the +Private Use Area, so pasted Nerd Font glyphs rendered as notdef boxes. + +Nerd Font family names already existed in `apps/web/lib/terminal/terminal-font.ts` +under the `icons` category, wired only to the terminal panel's +`terminalFontFamily` setting. + +## Acceptance + +- `NerdFontLocalGlyphs` covers all three PUA ranges and names every Nerd Font + family by its full and PostScript aliases from the pinned v3.5.0 release. +- `NerdFontBundledGlyphs` uses only the content-hashed bundled URL and declares + only ranges in the subset's actual character map. +- Bundled subset committed under `apps/web/public/fonts/nerd-symbols/` with its + MIT licence, served from the app's own origin. +- Local and bundled families present in that order in every application font + stack, after the UI typefaces. +- No text-processing code anywhere: bytes to the backend and agent unchanged. + +## Verification + +`cd apps/web && pnpm vitest run app/globals-font-fallback.test.ts` + +Then `pnpm run typecheck` and `pnpm lint`. + +Measured in a real browser against the running app: target codepoints render +the glyph rather than notdef; the bundled subset renders them with no `local()` +source present; ordinary text metrics are unchanged in an A/B with and without +the fallback. + +## Files Likely Touched + +- `apps/packages/theme/src/fonts.css` +- `apps/web/app/globals.css` +- `apps/web/app/globals-font-fallback.test.ts` +- `apps/web/public/fonts/nerd-symbols/nerd-symbols-subset-bca747e8.woff2` +- `apps/web/public/fonts/nerd-symbols/LICENSE` + +## Output Contract + +Report both faces, the declared ranges, the resolution order, the subset size +and coverage, tests run, and confirmation that no text-processing code was +introduced. diff --git a/docs/specs/prompt-paste-nerd-font-glyphs/spec.md b/docs/specs/prompt-paste-nerd-font-glyphs/spec.md new file mode 100644 index 0000000000..3bf3384ee9 --- /dev/null +++ b/docs/specs/prompt-paste-nerd-font-glyphs/spec.md @@ -0,0 +1,166 @@ +--- +status: building +created: 2026-08-19 +owner: jnmanso +--- + +# Render Nerd Font glyphs pasted from a styled terminal + +## Why +Text copied from a styled terminal prompt (Oh My Posh, Starship, any Nerd Font +setup) contains powerline separators and icon glyphs from the Unicode Private +Use Area. Pasted into a Kandev prompt input, each one renders as a notdef box +(square). Users read this as "Kandev deleted my character and replaced it with +a square". + +Nothing is deleted, and nothing is corrupted. The paste, the editor, the +WebSocket, the database and the agent all handle the text correctly: `U+E0B0` +is transmitted as its correct UTF-8 encoding `EE 82 B0`. The failure is purely +at render time, because no font in the UI stack has a glyph for those +codepoints. + +PUA codepoints cannot be resolved by platform font-matching the way CJK or +emoji are. Those carry a script and language association, so the OS can +nominate a covering font. The Private Use Area deliberately carries none: +`U+E0B0` means whatever the installed font decides. The OS has nothing to match +on, returns no font, and the browser draws notdef. A covering font must be +named explicitly, which is the same reason a user must configure their terminal +font to see powerline glyphs; installing the font is not sufficient. + +## What +- Prompt inputs and message surfaces SHALL render Private Use Area codepoints + as their intended glyphs. +- Resolution SHALL prefer a Nerd Font the user already has installed, and fall + back to a subset shipped by Kandev when they have none. + +### Font resolution order +- A local-only face SHALL list `local()` sources for the full Nerd Fonts + catalogue (66 patched families). A separate bundled-only face SHALL contain + the `url()` source. Every application font stack SHALL put the local face + before the bundled face, so an installed font wins and no download occurs. +- Every `local()` entry SHALL name a **full font name** (family plus style, for + example `MesloLGS Nerd Font Regular`) or a PostScript name. `local()` does + not match family names: `local("MesloLGS")` fails silently where + `local("MesloLGS Nerd Font Regular")` resolves. This is the inverse of the + `font-family` property and the failure is invisible, since an unmatched + source simply falls through. +- Ordering SHALL express intent rather than alphabetical accident, because + glyph shapes differ between families and the closest match to the user's + terminal is preferred: icons-only `Symbols`, then the Meslo variants that Oh + My Posh and Powerlevel10k recommend, then common programming faces, then the + remainder. +- Only Nerd Font patched faces SHALL be listed. Within one `src` list, a + resolved local resource does not retry the later URL for a missing glyph. + Separate font families let the browser continue from the selected local + resource to the bundled face for each missing character. Unpatched + `Cascadia Code` was listed as a Windows safety net and removed because it + covers 0 of the powerline, seti, devicon and octicon codepoints. +- Distributions of the same family may use different names, so covering the + Nerd Fonts release alone is not sufficient. Powerlevel10k ships its own + MesloLGS build, the most common way a styled-prompt user acquires a patched + font, declaring full name `MesloLGS NF Regular` and PostScript + `MesloLGS-NF-Regular`, where the v3.5.0 release file for the same family + declares `MesloLGS Nerd Font Regular` / `MesloLGSNF-Regular`. Both are + listed. +- Patched family names are not derivable from the original typeface + (`Source Code Pro` becomes `SauceCodePro`, `Cascadia Code` becomes + `CaskaydiaCove`, `IBM Plex Mono` becomes `BlexMono`), so the catalogue is + enumerated rather than inferred. + +### Bundled subset +- Kandev SHALL ship a subset of `Symbols Nerd Font` (the icons-only Nerd Fonts + build, MIT licensed) so users with no Nerd Font installed still see glyphs. +- The subset SHALL cover powerline separators (`U+E0A0-E0D4`), octicons + (`U+F400-F533`), seti/custom file and folder icons (`U+E5FA-E6B7`), and + devicons (`U+E700-E8EF`): the four sets an Oh My Posh prompt actually draws. +- The bundled face SHALL declare the subset's actual character map, including + gaps in the powerline range. An unsupported PUA codepoint SHALL NOT start a + download that cannot render it. The local face SHALL keep all three PUA + ranges so an installed font can render glyphs outside the subset. +- The subset SHALL be served from the application's own origin, never a + third-party CDN, which would leak that a user pasted terminal output and add + a runtime dependency on someone else's uptime. +- The subset filename SHALL contain the first eight characters of its SHA-256 + hash. Kandev serves static assets with a one-year immutable cache policy, so + a changed binary SHALL use a changed URL. +- Because the bundled face declares its exact `unicode-range`, the browser + fetches it only when a supported PUA codepoint needs that face. A user who + never renders supported PUA text downloads nothing. + +### Stack coverage +- The local and bundled glyph families SHALL be present in that order in every + font stack the stylesheet declares, not only the `--font-sans` and + `--font-mono` variables. + `.markdown-body` (rendered markdown across chat, PR and changelog) and + `.chat-message-list` hardcode their own families rather than reading the + variables, so a fix applied only to the variables leaves sent messages and + agent output rendering notdef while the composer renders glyphs. + +### Sizing +- Both faces SHALL carry a `size-adjust` descriptor. Powerline separators are + drawn to fill a full terminal cell, ascender to descender, measuring ~1.99x + the cap height of the UI typeface, and read as oversized blocks beside + proportional text. + +### Text sent to the agent is not modified +- The bytes delivered to the backend and the agent SHALL be byte-for-byte + identical to before this feature. No sanitization, stripping, substitution, + or normalization is applied to pasted text on any path. +- This is a hard requirement. Private Use codepoints may be meaningful to the + agent: a user pasting a prompt theme, a powerline configuration, or terminal + output may be asking about those exact characters, and removing them would + delete the subject of the question. +- The implementation satisfies this by construction rather than by discipline: + the feature is CSS only, and CSS cannot alter text content. + +## Failure modes +- **No Nerd Font installed, glyph inside the bundled subset.** The bundled + woff2 is fetched once and the glyph renders. Roughly 236 KB, cached, and + never fetched by users who paste no PUA text. +- **No Nerd Font installed, glyph outside the subset.** Notdef box, as today. + Affects the plane-15 Material Design range (`U+F0000-FFFFD`) and the Font + Awesome block, both excluded to keep the download small. The browser does + not fetch the subset for these unsupported codepoints. Widening requires + regenerating the subset from the pinned source archive, changing the hashed + filename, and updating its recorded hash. +- **An installed Nerd Font lacks one requested glyph.** The browser continues + to the separate bundled family for that character. One combined `src` list + would stop at the selected local resource instead. +- **A Nerd Font installed that is absent from the catalogue.** The local + sources miss, and resolution falls through to the bundled subset, so the + common glyphs still render. +- **A font in the stack claims a PUA codepoint unexpectedly.** `unicode-range` + scoping confines both faces to the PUA, so no ordinary text can change + appearance. + +## Scenarios +- **GIVEN** a user with a Nerd Font installed, **WHEN** they paste an Oh My + Posh prompt containing `U+E0B0`, **THEN** the separator renders using their + own font, matching the terminal they copied from, and no font is downloaded. +- **GIVEN** a user with no Nerd Font installed, **WHEN** they paste the same + text, **THEN** the bundled subset is fetched and the separator, git branch, + folder and devicon glyphs render. +- **GIVEN** a user who never pastes Private Use text, **WHEN** they use the + app, **THEN** the bundled subset is never requested. +- **GIVEN** any paste, **WHEN** the message is sent, **THEN** the backend and + the agent receive exactly the same bytes as before this feature, including + the Private Use codepoints. +- **GIVEN** ordinary text (accented Latin, CJK, emoji, box drawing), **WHEN** + it is rendered, **THEN** it uses the existing UI typeface with unchanged + metrics. +- **GIVEN** a maintainer edits the rule, **WHEN** a `local()` entry is reduced + to a family name, the bundled family is moved ahead of the local family, the + `url()` loses its content hash, the bundled range exceeds its character map, + or `size-adjust` is dropped, **THEN** the guard test fails. + +## Out of scope +- Shipping a complete Nerd Font. Only the icons-only subset is bundled, and + only for the four icon sets a terminal prompt uses. +- Modifying, stripping, or normalizing pasted text on any path. Explicitly + rejected: the agent must receive the characters. +- Rendering terminal background colours. Those are painted by the terminal and + are not present in the clipboard as text; reproducing them would require ANSI + parsing plus colour marks in the editor schema, a separate feature. +- Making the glyph font user-configurable. The terminal already exposes + `terminalFontFamily`; a separate preference for the composer is not justified + until someone asks for it.