fix(web): render pasted Nerd Font glyphs instead of notdef boxes (#2831)

Co-authored-by: Joao Manso <X363081@bcpcorp.net>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
João Manso
2026-08-20 14:57:51 +01:00
committed by GitHub
parent 8bf6b57c78
commit 48f2b6537c
8 changed files with 687 additions and 9 deletions
+105
View File
@@ -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;
}
+174
View File
@@ -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));
});
});
+14 -9
View File
@@ -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 */
@@ -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.
@@ -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.
@@ -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.
@@ -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.