fix(runner): self-host Phase 7 thread fonts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-10 23:19:28 +00:00
1 parent e995b59933
commit 011d5046b0
12 files changed
+301 -46

No files matched your search

@@ -51,6 +51,51 @@ async function seriousAxeViolations(page: Page): Promise<AxeViolation[]> {
}
test.describe("Phase 7G issue thread", () => {
test("loads the bundled sans and mono faces before the thread settles", async ({ page }) => {
await open(page, "thread-baseline");
const probe = await page.evaluate(async () => {
const normalizeFamily = (family: string) => family.trim().replace(/^['"]|['"]$/g, "");
const styles = getComputedStyle(document.documentElement);
const sansFamily = normalizeFamily(styles.getPropertyValue("--pit-font-sans").split(",")[0]);
const monoFamily = normalizeFamily(styles.getPropertyValue("--pit-font-mono").split(",")[0]);
const symbolFamily = normalizeFamily(styles.getPropertyValue("--pit-font-symbols"));
await document.fonts.load(`400 16px "${symbolFamily}"`, "◐⏳\uFE0E");
const statuses = (family: string) =>
[...document.fonts]
.filter((face) => normalizeFamily(face.family) === family)
.map((face) => face.status);
return {
sansFamily,
monoFamily,
symbolFamily,
sansStatuses: statuses(sansFamily),
monoStatuses: statuses(monoFamily),
symbolStatuses: statuses(symbolFamily),
sansWeightsLoaded: [400, 500, 600, 700].every((weight) =>
document.fonts.check(`${weight} 16px "${sansFamily}"`, "Paperclip"),
),
monoWeightsLoaded: [400, 700].every((weight) =>
document.fonts.check(`${weight} 16px "${monoFamily}"`, "PAP-17003"),
),
symbolsLoaded: document.fonts.check(`400 16px "${symbolFamily}"`, "◐⏳\uFE0E"),
};
});
expect(probe).toEqual({
sansFamily: "Paperclip Issue Thread Inter",
monoFamily: "Paperclip Issue Thread DejaVu Sans Mono",
symbolFamily: "Paperclip Issue Thread Symbols",
sansStatuses: ["loaded"],
monoStatuses: ["loaded", "loaded"],
symbolStatuses: ["loaded", "loaded"],
sansWeightsLoaded: true,
monoWeightsLoaded: true,
symbolsLoaded: true,
});
});
test("baseline thread renders identity, turns, and the §3 item types", async ({ page }) => {
await open(page, "thread-baseline");
@@ -240,14 +240,27 @@ export function App() {
useLayoutEffect(() => {
// A deep link has to land on what it addressed. With `rec` that is the
// record; without it, the opened section's header — otherwise the link
// half-arrives with the section still below the fold. Layout effect so the
// scroll is committed before paint and a capture can never race it.
// half-arrives with the section still below the fold. Wait for the bundled
// faces before measuring: a host fallback can otherwise move the retained
// scroll position by a pixel even after the final webfonts replace it.
if (route.panel === null || snapshot === null) return;
const target =
route.record === null
? document.querySelector(`[data-evidence-section="${CSS.escape(route.panel)}"]`)
: document.querySelector(`[data-record-id="${CSS.escape(route.record)}"]`);
if (target !== null) scrollEvidenceIntoView(target, route.record === null ? "start" : "center");
let cancelled = false;
void (async () => {
if (typeof document.fonts?.ready?.then === "function") {
await document.fonts.ready;
}
if (cancelled) return;
const target =
route.record === null
? document.querySelector(`[data-evidence-section="${CSS.escape(route.panel)}"]`)
: document.querySelector(`[data-record-id="${CSS.escape(route.record)}"]`);
if (target !== null) {
scrollEvidenceIntoView(target, route.record === null ? "start" : "center");
}
})();
return () => {
cancelled = true;
};
}, [route.panel, route.record, snapshot, panelOpen, segment, openSections]);
useEffect(() => {
@@ -0,0 +1,78 @@
# Phase 7 issue-thread fonts
The Phase 7 issue-thread bundle includes Latin-subset WOFF2 files so captures
do not depend on fonts installed on the host.
## Inter
- Upstream: https://github.com/rsms/inter
- Version: 4.1
- Source: `ui/public/fonts/InterVariable.woff2`
- License: SIL Open Font License 1.1
- License text: https://github.com/rsms/inter/blob/v4.1/LICENSE.txt
The bundled file is subset from the repository's unmodified upstream Inter 4.1
variable WOFF2. CSS exposes weights 400 through 700.
## DejaVu Sans Mono
- Upstream: https://dejavu-fonts.github.io/
- Version: 2.37
- Source faces: DejaVu Sans Mono Book and Bold; DejaVu Sans Book for status glyphs
- License: Bitstream Vera Fonts license; DejaVu changes are public domain
- License text: https://dejavu-fonts.github.io/License.html
The bundled files are Latin subsets of the Book and Bold faces. The source font
copyright and permission notice follow.
> Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a
> trademark of Bitstream, Inc.
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of the fonts accompanying this license ("Fonts") and associated documentation
> files (the "Font Software"), to reproduce and distribute the Font Software,
> including without limitation the rights to use, copy, merge, publish,
> distribute, and/or sell copies of the Font Software, and to permit persons to
> whom the Font Software is furnished to do so, subject to the following
> conditions:
>
> The above copyright and trademark notices and this permission notice shall be
> included in all copies of one or more of the Font Software typefaces.
>
> The Font Software may be modified, altered, or added to, and in particular the
> designs of glyphs or characters in the Fonts may be modified and additional
> glyphs or characters may be added to the Fonts, only if the fonts are renamed
> to names not containing either the words "Bitstream" or the word "Vera".
>
> This License becomes null and void to the extent applicable to Fonts or Font
> Software that has been modified and is distributed under the "Bitstream Vera"
> names.
>
> The Font Software may be sold as part of a larger software package but no copy
> of one or more of the Font Software typefaces may be sold by itself.
>
> THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
> OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
> TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION
> BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
> SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION
> OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO
> USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
>
> Except as contained in this notice, the names of Gnome, the Gnome Foundation,
> and Bitstream Inc., shall not be used in advertising or otherwise to promote
> the sale, use or other dealings in the Font Software without prior written
> authorization from the Gnome Foundation or Bitstream Inc., respectively.
## Noto Sans Symbols 2
- Upstream: https://github.com/notofonts/symbols
- Version: 2.003
- Source face: Noto Sans Symbols 2 Regular
- License: SIL Open Font License 1.1
- License text: https://github.com/notofonts/symbols/blob/main/OFL.txt
The 1.2 kB bundled subset contains only the two hourglass glyphs that are not
present in Inter or DejaVu Sans. It is exposed under the same package-specific
symbol fallback family using a disjoint `unicode-range`.
@@ -6,6 +6,52 @@
* raw color literals — `pnpm check:browser-tokens` enforces that.
*/
@font-face {
font-family: "Paperclip Issue Thread Inter";
src: url("./fonts/inter-latin.woff2") format("woff2");
font-style: normal;
font-weight: 400 700;
font-display: block;
}
@font-face {
font-family: "Paperclip Issue Thread DejaVu Sans Mono";
src: url("./fonts/dejavu-sans-mono-latin-400.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: block;
}
@font-face {
font-family: "Paperclip Issue Thread DejaVu Sans Mono";
src: url("./fonts/dejavu-sans-mono-latin-700.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: block;
}
/* Inter intentionally omits several status glyphs. Keep those deterministic
with two tiny, disjoint symbol subsets instead of consulting host fonts. */
@font-face {
font-family: "Paperclip Issue Thread Symbols";
src: url("./fonts/dejavu-sans-symbols.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: block;
unicode-range: U+00A7, U+00B7, U+2013-2014, U+2022, U+2026, U+203A, U+2192,
U+22EF, U+2304, U+25AC, U+25B2, U+25B6, U+25BC, U+25C6, U+25CB, U+25D0-25D1,
U+2699, U+26A0, U+2713, U+2715, U+275A;
}
@font-face {
font-family: "Paperclip Issue Thread Symbols";
src: url("./fonts/noto-sans-symbols2-hourglass.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: block;
unicode-range: U+231B, U+23F3, U+FE0E;
}
:root {
color-scheme: dark;
@@ -41,8 +87,9 @@
--pit-ring: oklch(0.78 0.11 275);
/* Type ramp */
--pit-font-sans: "Inter", "DejaVu Sans", ui-sans-serif, system-ui, sans-serif;
--pit-font-mono: "DejaVu Sans Mono", ui-monospace, "SFMono-Regular", monospace;
--pit-font-sans: "Paperclip Issue Thread Inter", "Paperclip Issue Thread Symbols", "Inter", ui-sans-serif, system-ui, sans-serif;
--pit-font-mono: "Paperclip Issue Thread DejaVu Sans Mono", "Paperclip Issue Thread Symbols", "DejaVu Sans Mono", ui-monospace, monospace;
--pit-font-symbols: "Paperclip Issue Thread Symbols";
--pit-text-xs: 0.75rem;
--pit-text-sm: 0.875rem;
--pit-text-base: 1rem;
@@ -94,6 +141,7 @@ body {
*/
body {
font-family: var(--pit-font-sans);
font-variant-emoji: text;
font-size: var(--pit-text-sm);
line-height: var(--pit-leading-normal);
}
@@ -168,17 +168,17 @@ Hosts without the Playwright chromium system libraries can either run
`pnpm --filter @paperclipai/paperclip-runner verify:rootless` or set
`PAPERCLIP_RUNNER_CHROMIUM_PATH` to a preinstalled Chromium.
The committed PNGs are pinned to the Chromium build and font environment listed
in `knowledge/evidence/phase-07/ui/index.md`, so `check:phase7:ui` needs that
same browser and font set. Point `PAPERCLIP_RUNNER_CHROMIUM_PATH` at the
recorded browser before comparing — and when that path is the agent-browser
wrapper, also set `PAPERCLIP_CHROMIUM_BIN` to the exact binary, because the
wrapper otherwise picks the newest installed Playwright Chromium. A different
build or font set makes every shot mismatch at once, which reads like a
regression but is not one. The recorder probes that `Inter` resolves distinctly
from the DejaVu Sans fallback and refuses to record or compare when it does
not; the drift report prints the Chromium version and font probe it recorded
with.
The committed PNGs are pinned to the Chromium build listed in
`knowledge/evidence/phase-07/ui/index.md`, so `check:phase7:ui` needs that same
browser. Point `PAPERCLIP_RUNNER_CHROMIUM_PATH` at the recorded browser before
comparing — and when that path is the agent-browser wrapper, also set
`PAPERCLIP_CHROMIUM_BIN` to the exact binary, because the wrapper otherwise
picks the newest installed Playwright Chromium. The issue-thread bundle
self-hosts its Latin Inter and DejaVu Sans Mono WOFF2 faces plus tiny status-glyph
subsets, so host fontconfig directories do not participate in capture. The recorder probes the
package-specific bundled families and refuses to record or compare when either
face is absent or fails to load; the drift report prints the Chromium version
and bundled-font probe it recorded with.
## Accessibility
@@ -81,30 +81,95 @@ async function startPreview() {
// Set by `record` so a drift report can name the Chromium build that produced it.
let browserVersion = "unknown";
let fontProbe = { interWidth: 0, dejavuWidth: 0, interResolves: false };
let fontProbe = {
sansFamily: "Paperclip Issue Thread Inter",
monoFamily: "Paperclip Issue Thread DejaVu Sans Mono",
symbolFamily: "Paperclip Issue Thread Symbols",
interWidth: 0,
serifWidth: 0,
monoWidth: 0,
sansStatuses: [],
monoStatuses: [],
symbolStatuses: [],
interResolves: false,
monoResolves: false,
symbolsResolve: false,
};
/**
* The committed matrix is recorded with Inter active. When fontconfig cannot
* resolve Inter, every text node falls back to DejaVu Sans and all 24 shots
* drift at once — refuse to record or compare under that environment instead
* of producing a whole-page mystery diff.
* Prove that the Vite-managed font faces registered and loaded from the built
* bundle. The family names are package-specific, so an ambient host font can
* never make this probe pass when either asset is missing.
*/
async function probeFonts(browser) {
const context = await browser.newContext();
const page = await context.newPage();
const probe = await page.evaluate(() => {
const canvas = document.createElement("canvas").getContext("2d");
await page.goto(routeFor("thread-baseline", "desktop"));
await page.waitForSelector('[data-thread-state="settled"]', { timeout: 30_000 });
const probe = await page.evaluate(async () => {
const normalizeFamily = (family) => family.trim().replace(/^['"]|['"]$/g, "");
const styles = getComputedStyle(document.documentElement);
const sansFamily = normalizeFamily(styles.getPropertyValue("--pit-font-sans").split(",")[0]);
const monoFamily = normalizeFamily(styles.getPropertyValue("--pit-font-mono").split(",")[0]);
const symbolFamily = normalizeFamily(styles.getPropertyValue("--pit-font-symbols"));
const sample = "Wire the runner spike to the mock control plane";
const requested = [
...[400, 500, 600, 700].map((weight) => ({ family: sansFamily, weight })),
...[400, 700].map((weight) => ({ family: monoFamily, weight })),
{ family: symbolFamily, weight: 400, sample: "◐⏳\uFE0E" },
];
const loadResults = await Promise.all(
requested.map(async ({ family, weight, sample: faceSample }) => {
try {
const loaded = await document.fonts.load(
`${weight} 24px "${family}"`,
faceSample ?? sample,
);
return loaded.length > 0;
} catch {
return false;
}
}),
);
await document.fonts.ready;
const faceStatuses = (family) =>
[...document.fonts]
.filter((face) => normalizeFamily(face.family) === family)
.map((face) => face.status);
const canvas = document.createElement("canvas").getContext("2d");
const width = (font) => {
canvas.font = font;
return Math.round(canvas.measureText(sample).width);
};
const interWidth = width("700 24px Inter");
const dejavuWidth = width('700 24px "DejaVu Sans"');
const sansStatuses = faceStatuses(sansFamily);
const monoStatuses = faceStatuses(monoFamily);
const symbolStatuses = faceStatuses(symbolFamily);
return {
interWidth,
dejavuWidth,
interResolves: document.fonts.check("16px Inter") && interWidth !== dejavuWidth,
sansFamily,
monoFamily,
symbolFamily,
interWidth: width(`700 24px "${sansFamily}"`),
serifWidth: width("700 24px serif"),
monoWidth: width(`400 24px "${monoFamily}"`),
sansStatuses,
monoStatuses,
symbolStatuses,
interResolves:
sansFamily === "Paperclip Issue Thread Inter" &&
sansStatuses.length === 1 &&
sansStatuses.every((status) => status === "loaded") &&
loadResults.slice(0, 4).every(Boolean),
monoResolves:
monoFamily === "Paperclip Issue Thread DejaVu Sans Mono" &&
monoStatuses.length === 2 &&
monoStatuses.every((status) => status === "loaded") &&
loadResults.slice(4, 6).every(Boolean),
symbolsResolve:
symbolFamily === "Paperclip Issue Thread Symbols" &&
symbolStatuses.length === 2 &&
symbolStatuses.every((status) => status === "loaded") &&
loadResults[6] === true,
};
});
await context.close();
@@ -122,12 +187,13 @@ async function record(targetDir) {
const recorded = [];
try {
fontProbe = await probeFonts(browser);
if (!fontProbe.interResolves) {
if (!fontProbe.interResolves || !fontProbe.monoResolves || !fontProbe.symbolsResolve) {
throw new Error(
`font environment mismatch: "Inter" does not resolve distinctly ` +
`(Inter ${fontProbe.interWidth}px vs DejaVu Sans ${fontProbe.dejavuWidth}px). ` +
"The committed matrix is recorded with Inter active; make Inter visible to " +
"fontconfig before recording or checking.",
"bundled font asset mismatch: the Phase 7 issue-thread faces did not load " +
`from the built bundle (sans ${fontProbe.sansFamily}: ${fontProbe.sansStatuses.join(",") || "missing"}; ` +
`mono ${fontProbe.monoFamily}: ${fontProbe.monoStatuses.join(",") || "missing"}; ` +
`symbols ${fontProbe.symbolFamily}: ${fontProbe.symbolStatuses.join(",") || "missing"}). ` +
"Run build:issue-thread and verify that Vite emitted all referenced WOFF2 assets.",
);
}
for (const viewport of VIEWPORTS) {
@@ -189,9 +255,12 @@ async function main() {
`- Chromium ${browserVersion} — pin the exact binary with \`PAPERCLIP_CHROMIUM_BIN\``,
" (honoured by the agent-browser wrapper) alongside",
" `PAPERCLIP_RUNNER_CHROMIUM_PATH`.",
`- Sans stack resolves to Inter (probe: Inter ${fontProbe.interWidth}px vs`,
` DejaVu Sans ${fontProbe.dejavuWidth}px for the title string). The recorder`,
" refuses to run when Inter does not resolve.",
`- Bundled sans face: ${fontProbe.sansFamily} (weights 400/500/600/700,`,
` probe ${fontProbe.interWidth}px vs generic serif ${fontProbe.serifWidth}px).`,
`- Bundled mono face: ${fontProbe.monoFamily} (weights 400/700,`,
` probe ${fontProbe.monoWidth}px). Status glyphs use the bundled`,
` ${fontProbe.symbolFamily} subsets. The recorder refuses to run when any`,
" Vite-managed face is missing or fails to load.",
"",
].join("\n"),
"utf8",
@@ -230,12 +299,11 @@ async function main() {
"Phase 7 UI evidence is not byte-stable:",
...drift,
"",
`Recorded with Chromium ${browserVersion}; sans probe Inter=${fontProbe.interWidth}px, DejaVu Sans=${fontProbe.dejavuWidth}px.`,
"Committed PNGs are pinned to one Chromium build and one font environment.",
"A wholesale mismatch usually means a different browser or font set, not a",
"UI regression: set PAPERCLIP_RUNNER_CHROMIUM_PATH (and PAPERCLIP_CHROMIUM_BIN",
"for the wrapper) to the Chromium that recorded them, and compare the font",
"probe against knowledge/evidence/phase-07/ui/index.md.",
`Recorded with Chromium ${browserVersion}; bundled Inter probe=${fontProbe.interWidth}px, mono=${fontProbe.monoWidth}px.`,
"Committed PNGs are pinned to one Chromium build; their fonts are bundled.",
"A wholesale mismatch usually means a different browser build, not ambient",
"fontconfig state: set PAPERCLIP_RUNNER_CHROMIUM_PATH (and",
"PAPERCLIP_CHROMIUM_BIN for the wrapper) to the Chromium that recorded them.",
"",
].join("\n"),
);
@@ -18,5 +18,8 @@ export default defineConfig({
outDir: resolve(packageRoot, "dist-issue-thread"),
emptyOutDir: true,
target: "esnext",
// Keep every WOFF2 inspectable in the built asset graph, including the
// intentionally tiny supplemental-symbol subset.
assetsInlineLimit: 0,
},
});