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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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.
|
||||
Reference in New Issue
Block a user