From c77daa31ff2d5ca1e766c6a31c76410bbf77760c Mon Sep 17 00:00:00 2001 From: Moritz Wilksch Date: Fri, 18 Sep 2026 14:04:35 +0000 Subject: [PATCH] fix(screenshot-change): render design fonts and capture at 2x A Linux capture host has neither `ui-monospace` nor `system-ui`, so fontconfig substituted DejaVu and captures looked nothing like the app; the harness now fronts vendored JetBrains Mono and Inter in `--mono`/`--sans` before the first paint. `deviceScaleFactor` was pinned to 1, halving the density of a HiDPI display. `newPage` now captures at 2x by default and exposes `scale`; video stays at 1x since 2x mp4 mostly wastes bytes. --- .agents/skills/screenshot-change/SETUP.md | 2 + .agents/skills/screenshot-change/SKILL.md | 3 +- .../screenshot-change/package-lock.json | 22 ++++++ .agents/skills/screenshot-change/package.json | 4 +- .../screenshot-change/scripts/harness.mjs | 72 +++++++++++++++++-- 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/.agents/skills/screenshot-change/SETUP.md b/.agents/skills/screenshot-change/SETUP.md index 1ee7578..7235a3d 100644 --- a/.agents/skills/screenshot-change/SETUP.md +++ b/.agents/skills/screenshot-change/SETUP.md @@ -15,6 +15,8 @@ cd .agents/skills/screenshot-change && npx playwright install chromium --only-sh ffmpeg -hide_banner -encoders | grep libx264 ``` +The install also pulls JetBrains Mono and Inter. The harness fronts them in the app's `--mono`/`--sans` tokens, so a host without the design's macOS fonts captures the intended typography instead of DejaVu. + - The harness resolves Playwright from the skill directory first, then the checkout, then `npm root -g`. A bare `import 'playwright'` in a scenario does **not** see any of them; use the harness's `openBrowser()`. - Chromium needs shared libraries (`libnspr4`, `libnss3`, and friends). `openBrowser()` adds them from `PLAYWRIGHT_LIBS`, or from `~/.pixi/envs/chromelibs/lib` when that env is unset and the directory exists. A machine with neither fails with `cannot open shared object file`. - The webm to mp4 conversion needs a system `ffmpeg` with `libx264`. Playwright's bundled ffmpeg lacks the encoder. diff --git a/.agents/skills/screenshot-change/SKILL.md b/.agents/skills/screenshot-change/SKILL.md index 8d5761f..0627e6f 100644 --- a/.agents/skills/screenshot-change/SKILL.md +++ b/.agents/skills/screenshot-change/SKILL.md @@ -25,7 +25,8 @@ Record behavior that unfolds over time: key presses, cursor jumps, collapses. St ## Notes -- **Crop to the element.** Read back only the crops: a 500×200 crop costs a fraction of a 1440×900 frame. +- **Crop to the element.** Read back only the crops: a 500×200 crop costs a fraction of a 1440×900 frame. Captures are 2x by default (`newPage(..., { scale })`), so a crop's pixel count is four times its CSS box. +- **The harness sets the type.** `newPage`/`newVideoPage` front the skill's vendored JetBrains Mono and Inter in the app's `--mono`/`--sans` tokens before the first paint, so a host without the design's fonts still captures it. The design runs on `ui-monospace`/`system-ui`, which a minimal Linux host resolves to DejaVu. - **Seed over HTTP.** Clicking state through the UI is slower and flakier than one request. - **Wait on a signal.** `locator.waitFor()` beats `waitForTimeout`; keep timeouts for animations only. - **Locators pierce the shadow DOM, `page.evaluate` does not.** The viewer and file tree render into shadow roots, so reach for Playwright locators (or the verbs) instead of `querySelector` inside `evaluate`. diff --git a/.agents/skills/screenshot-change/package-lock.json b/.agents/skills/screenshot-change/package-lock.json index ba80ba5..411d52f 100644 --- a/.agents/skills/screenshot-change/package-lock.json +++ b/.agents/skills/screenshot-change/package-lock.json @@ -6,9 +6,31 @@ "": { "name": "diffle-screenshot-change", "devDependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "playwright": "^1.63.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/playwright": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", diff --git a/.agents/skills/screenshot-change/package.json b/.agents/skills/screenshot-change/package.json index b365567..0c0158e 100644 --- a/.agents/skills/screenshot-change/package.json +++ b/.agents/skills/screenshot-change/package.json @@ -1,8 +1,10 @@ { "name": "diffle-screenshot-change", "private": true, - "description": "Playwright for the screenshot-change skill harness. Kept out of the root manifest so the app's install and CI do not pull it.", + "description": "Playwright and the design fonts for the screenshot-change skill harness. Kept out of the root manifest so the app's install and CI do not pull them.", "devDependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "playwright": "^1.63.0" } } diff --git a/.agents/skills/screenshot-change/scripts/harness.mjs b/.agents/skills/screenshot-change/scripts/harness.mjs index 8c37f32..cb8ae14 100644 --- a/.agents/skills/screenshot-change/scripts/harness.mjs +++ b/.agents/skills/screenshot-change/scripts/harness.mjs @@ -10,7 +10,7 @@ // `frame`/`videoDuration` read a recording back. import { execFileSync, spawn } from 'node:child_process'; import { existsSync, rmSync, symlinkSync } from 'node:fs'; -import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -77,6 +77,60 @@ export async function openBrowser() { return chromium.launch(); } +// The design names macOS-only families (`ui-monospace`, `system-ui`). A Linux capture host without +// them substitutes DejaVu, so a screenshot no longer looks like the app. The skill vendors JetBrains +// Mono and Inter; front them in the app's `--mono`/`--sans` tokens before the first paint, so the +// viewer measures the final font. A checkout without the skill's install skips this. +const FONTS = [ + { + pkg: '@fontsource-variable/jetbrains-mono', + file: 'jetbrains-mono-latin-wght-normal.woff2', + family: 'JetBrains Mono Variable', + weight: '100 800', + token: "--mono:'JetBrains Mono Variable',ui-monospace,monospace", + }, + { + pkg: '@fontsource-variable/inter', + file: 'inter-latin-wght-normal.woff2', + family: 'Inter Variable', + weight: '100 900', + token: "--sans:'Inter Variable',system-ui,sans-serif", + }, +]; + +/** The `@font-face` + token override CSS, or null when the skill's font packages are not installed. */ +let fontsCss; +function loadFontsCss() { + fontsCss ??= (async () => { + const require = createRequire(join(SKILL_ROOT, 'package.json')); + const faces = []; + const tokens = []; + for (const font of FONTS) { + let path; + try { + path = require.resolve(`${font.pkg}/files/${font.file}`); + } catch { + return null; + } + const data = await readFile(path, 'base64'); + faces.push( + `@font-face{font-family:'${font.family}';font-style:normal;font-display:block;` + + `font-weight:${font.weight};src:url(data:font/woff2;base64,${data}) format('woff2-variations')}`, + ); + tokens.push(font.token); + } + return `${faces.join('')}\n:root{${tokens.join(';')}}`; + })(); + return fontsCss; +} + +/** Applied by `addInitScript`, so it runs before the app's first paint. */ +function injectFontStyle(css) { + const style = document.createElement('style'); + style.textContent = css; + (document.head ?? document.documentElement).append(style); +} + /** Wait until the viewer has rendered a line, so captures are not blank. */ export async function waitForViewer(page) { await page.locator('.codeview').waitFor({ timeout: 15000 }); @@ -87,11 +141,18 @@ export async function waitForViewer(page) { .catch(() => {}); } -/** Open a page on `url` at a desktop viewport, the default screenshot frame. */ -export async function newPage(browser, url, { width = 1440, height = 900, colorScheme = 'light' } = {}) { - const context = await browser.newContext({ colorScheme, deviceScaleFactor: 1, viewport: { width, height } }); +/** + * Open a page on `url` at a desktop viewport, the default screenshot frame. `scale` is the capture + * DPR: 2 by default so glyphs rasterize at 2x and a crop stays crisp on a HiDPI display. It only + * changes `crop`/`clip` output size; Playwright boxes and mouse coordinates stay in CSS pixels. + */ +export async function newPage(browser, url, { width = 1440, height = 900, colorScheme = 'light', scale = 2 } = {}) { + const context = await browser.newContext({ colorScheme, deviceScaleFactor: scale, viewport: { width, height } }); + const css = await loadFontsCss(); + if (css) await context.addInitScript(injectFontStyle, css); const page = await context.newPage(); await page.goto(url); + await page.evaluate(() => document.fonts.ready); await waitForViewer(page); return page; } @@ -250,8 +311,11 @@ export async function newVideoPage(browser, url, { width = 1280, height = 800, c viewport: { width, height }, recordVideo: { dir, size: { width, height } }, }); + const css = await loadFontsCss(); + if (css) await context.addInitScript(injectFontStyle, css); const page = await context.newPage(); await page.goto(url); + await page.evaluate(() => document.fonts.ready); await waitForViewer(page); return { page, context, video: page.video() }; }