diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index c170a8fdc64..7d14f078b6b 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -51,6 +51,51 @@ async function seriousAxeViolations(page: Page): Promise { } 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"); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index 1abd3bb98a2..287a875362d 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -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(() => { diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/NOTICE.md b/packages/paperclip-runner/devtools/issue-thread/src/fonts/NOTICE.md new file mode 100644 index 00000000000..6ac2eed8262 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/src/fonts/NOTICE.md @@ -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`. diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-400.woff2 b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-400.woff2 new file mode 100644 index 00000000000..7700aef0cc9 Binary files /dev/null and b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-400.woff2 differ diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-700.woff2 b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-700.woff2 new file mode 100644 index 00000000000..83d6fc37b22 Binary files /dev/null and b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-700.woff2 differ diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-symbols.woff2 b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-symbols.woff2 new file mode 100644 index 00000000000..a7867066889 Binary files /dev/null and b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-symbols.woff2 differ diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/inter-latin.woff2 b/packages/paperclip-runner/devtools/issue-thread/src/fonts/inter-latin.woff2 new file mode 100644 index 00000000000..8f09bddd4d8 Binary files /dev/null and b/packages/paperclip-runner/devtools/issue-thread/src/fonts/inter-latin.woff2 differ diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/noto-sans-symbols2-hourglass.woff2 b/packages/paperclip-runner/devtools/issue-thread/src/fonts/noto-sans-symbols2-hourglass.woff2 new file mode 100644 index 00000000000..7c24a451df6 Binary files /dev/null and b/packages/paperclip-runner/devtools/issue-thread/src/fonts/noto-sans-symbols2-hourglass.woff2 differ diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css index b56151c85e1..0e61e04906c 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css +++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css @@ -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); } diff --git a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md index 684f3ff3c28..2981422d91e 100644 --- a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md +++ b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md @@ -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 diff --git a/packages/paperclip-runner/scripts/record-phase7-ui-evidence.mjs b/packages/paperclip-runner/scripts/record-phase7-ui-evidence.mjs index 5a288324083..49a5bd86783 100644 --- a/packages/paperclip-runner/scripts/record-phase7-ui-evidence.mjs +++ b/packages/paperclip-runner/scripts/record-phase7-ui-evidence.mjs @@ -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"), ); diff --git a/packages/paperclip-runner/vite.issue-thread.config.ts b/packages/paperclip-runner/vite.issue-thread.config.ts index c5320ac9baf..c65914ed3c4 100644 --- a/packages/paperclip-runner/vite.issue-thread.config.ts +++ b/packages/paperclip-runner/vite.issue-thread.config.ts @@ -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, }, });