Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/screenshot-change/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/screenshot-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
22 changes: 22 additions & 0 deletions .agents/skills/screenshot-change/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion .agents/skills/screenshot-change/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
72 changes: 68 additions & 4 deletions .agents/skills/screenshot-change/scripts/harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
}
Expand Down Expand Up @@ -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() };
}
Expand Down
Loading