From 12140361aaad4d4d0e88750ffcb92994c0061e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Sat, 10 Oct 2026 12:35:51 -0600 Subject: [PATCH 01/11] ci: enforce the performance budget --- .github/workflows/perf.yml | 76 ++++++++++++++++++++++++++++++++++++++ client/scripts/browser.ts | 30 +++++++++++++++ client/scripts/budgets.ts | 53 ++++++++++++++++++++++++++ client/scripts/perf.ts | 68 ++++++++++++++++++++-------------- docs/performance.md | 27 +++++++++----- 5 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/perf.yml create mode 100644 client/scripts/browser.ts create mode 100644 client/scripts/budgets.ts diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 0000000..9dad252 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,76 @@ +name: perf + +on: + pull_request: + paths: + - client/** + - docs/performance.md + - .github/workflows/perf.yml + push: + branches: [main] + paths: + - client/** + - docs/performance.md + - .github/workflows/perf.yml + +permissions: + contents: read + +concurrency: + group: perf-${{ github.ref }} + cancel-in-progress: true + +jobs: + budget: + name: performance budget + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + - name: Install + working-directory: client + run: bun install --frozen-lockfile + - name: Install Chrome + id: chrome + uses: browser-actions/setup-chrome@54e7c7bf1c147ffca98dc47b796907454c8c1787 # setup-chrome-v2.2.0 + with: + chrome-version: 154.0.8037.92 + install-dependencies: true + - name: Build + working-directory: client + run: bun run build + - name: Serve the production build + working-directory: client + run: | + bun run preview --host 127.0.0.1 --port 4173 --strictPort > "$RUNNER_TEMP/preview.log" 2>&1 & + for _ in $(seq 1 30); do + curl --silent --fail --output /dev/null http://127.0.0.1:4173/ && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/preview.log" + echo "Preview server did not start" >&2 + exit 1 + - name: Check the budgets + working-directory: client + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: bun run perf http://127.0.0.1:4173/ --json > "$RUNNER_TEMP/perf.json" + - name: Summarize + if: always() + run: | + test -s "$RUNNER_TEMP/perf.json" || exit 0 + { + echo "### Performance budget" + jq -r '"- Cold load: \(.load.bytesKB) KB of \(.budgets.transferKB) KB\n- Layout shift: \(.load.clsScore) of \(.budgets.layoutShift)\n- Longest task: \(.load.longTasks.maxMs) ms of \(.budgets.longTaskMs) ms\n- Frame p99 at 4x throttle: \(([.frames[] | select(.throttle == 4)][0].p99)) ms of \(.budgets.frameP99Ms) ms"' "$RUNNER_TEMP/perf.json" + jq -r '.violations[] | "- **Over budget:** \(.message)"' "$RUNNER_TEMP/perf.json" + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: perf-report + path: ${{ runner.temp }}/perf.json + if-no-files-found: ignore diff --git a/client/scripts/browser.ts b/client/scripts/browser.ts new file mode 100644 index 0000000..5e22b55 --- /dev/null +++ b/client/scripts/browser.ts @@ -0,0 +1,30 @@ +/** Shared headless Chrome setup for the measurement scripts (perf, a11y). */ +import puppeteer, { type Browser, type Page } from 'puppeteer-core'; + +const DEFAULT_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 } as const; + +/** Hosts the game talks to. Blocking them forces the offline practice run. */ +export const STELLAR_HOSTS: readonly string[] = [ + 'soroban-testnet.stellar.org', + 'horizon-testnet.stellar.org', + 'friendbot.stellar.org', +]; + +export async function launchBrowser(): Promise { + return puppeteer.launch({ + executablePath: process.env.CHROME_PATH ?? DEFAULT_CHROME, + headless: true, + args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', `--window-size=${VIEWPORT.width},${VIEWPORT.height}`], + defaultViewport: VIEWPORT, + }); +} + +/** Aborts every request to a Stellar host so the game runs fully offline. */ +export async function blockStellar(page: Page): Promise { + await page.setRequestInterception(true); + page.on('request', (req) => { + if (STELLAR_HOSTS.includes(new URL(req.url()).host)) void req.abort(); + else void req.continue(); + }); +} diff --git a/client/scripts/budgets.ts b/client/scripts/budgets.ts new file mode 100644 index 0000000..47ea1dc --- /dev/null +++ b/client/scripts/budgets.ts @@ -0,0 +1,53 @@ +/** + * Performance budgets, the single source for the numbers written in + * docs/performance.md. The harness imports this to fail a run that goes over. + */ + +export const BUDGETS = { + /** Encoded bytes transferred on a cold load, in KB. */ + transferKB: 1200, + /** Cumulative layout shift during load. */ + layoutShift: 0.05, + /** Longest task allowed during load, in ms. The Long Tasks API only reports tasks over 50 ms. */ + longTaskMs: 50, + /** 99th percentile frame time in play with the CPU throttled 4 times, in ms. */ + frameP99Ms: 20, +} as const; + +/** CPU throttle factor the frame budget applies to. */ +export const BUDGET_THROTTLE = 4; + +export interface BudgetInput { + load: { bytesKB: number; clsScore: number; longTasks: { count: number; totalMs: number; maxMs: number } }; + frames: { throttle: number; p99: number }[]; +} + +export interface Violation { + metric: string; + actual: number; + limit: number; + message: string; +} + +/** Compares a measured report with the budgets and returns one entry per breach. */ +export function checkBudgets(report: BudgetInput): Violation[] { + const out: Violation[] = []; + const over = (metric: string, actual: number, limit: number, unit: string, hint: string): void => { + if (actual > limit) { + out.push({ metric, actual, limit, message: `${metric} is ${actual}${unit}, budget is ${limit}${unit}. ${hint}` }); + } + }; + + over('Cold load transfer', report.load.bytesKB, BUDGETS.transferKB, ' KB', 'Check for a new or unoptimized asset.'); + over('Cumulative layout shift', report.load.clsScore, BUDGETS.layoutShift, '', 'Reserve space for late content or load styles before first paint.'); + if (report.load.longTasks.count > 0) { + over('Longest task during load', report.load.longTasks.maxMs, BUDGETS.longTaskMs, ' ms', 'Split or defer the work that blocks the main thread.'); + } + const throttled = report.frames.find((f) => f.throttle === BUDGET_THROTTLE); + if (!throttled) { + out.push({ metric: 'Frame time', actual: 0, limit: BUDGETS.frameP99Ms, message: `No frame sample at ${BUDGET_THROTTLE}x CPU throttle was recorded.` }); + } else { + over(`Frame time p99 at ${BUDGET_THROTTLE}x throttle`, throttled.p99, BUDGETS.frameP99Ms, ' ms', 'Profile the render loop for per frame allocations.'); + } + return out; +} diff --git a/client/scripts/perf.ts b/client/scripts/perf.ts index ce035df..37a55f0 100644 --- a/client/scripts/perf.ts +++ b/client/scripts/perf.ts @@ -4,18 +4,24 @@ * the background. * * bun run build && bun run preview --port 4173 & - * bun run perf [url] + * bun run perf [url] [--json] + * + * With --json the report goes to stdout as JSON and the human readable log to + * stderr. The exit code is 1 when a budget in scripts/budgets.ts is exceeded, + * which is what the perf workflow relies on. * * It measures a cold load (bytes, first paint, largest paint, long tasks, time * until the title is usable) and then plays a practice run with the Stellar * network blocked, sampling frame times at 1x and 4x CPU throttling. */ -import puppeteer, { type Page } from 'puppeteer-core'; +import type { Page } from 'puppeteer-core'; +import { blockStellar, launchBrowser } from './browser'; +import { BUDGETS, BUDGET_THROTTLE, checkBudgets, type Violation } from './budgets'; -const URL_UNDER_TEST = process.argv[2] ?? 'http://127.0.0.1:4173/'; -const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ARGS = process.argv.slice(2); +const JSON_OUTPUT = ARGS.includes('--json'); +const URL_UNDER_TEST = ARGS.find((a) => !a.startsWith('--')) ?? 'http://127.0.0.1:4173/'; const SAMPLE_MS = 12_000; -const STELLAR_HOSTS = ['soroban-testnet.stellar.org', 'horizon-testnet.stellar.org', 'friendbot.stellar.org']; interface LoadReport { bytesKB: number; @@ -24,11 +30,16 @@ interface LoadReport { fcpMs: number | null; lcpMs: number | null; clsScore: number; - longTasks: { count: number; totalMs: number }; + longTasks: { count: number; totalMs: number; maxMs: number }; titleReadyMs: number | null; jsHeapMB: number; } +/** Human readable output. Goes to stderr in --json mode so stdout stays parseable. */ +function log(...parts: unknown[]): void { + (JSON_OUTPUT ? console.error : console.log)(...parts); +} + interface FrameReport { throttle: number; frames: number; @@ -102,7 +113,7 @@ async function coldLoad(page: Page): Promise { }); const shifts = await page.evaluate(() => (window as unknown as { __shifts?: string[] }).__shifts ?? []); - if (shifts.length > 0) console.log('LAYOUT SHIFTS', shifts); + if (shifts.length > 0) log('LAYOUT SHIFTS', shifts); return { bytesKB: Math.round(bytes / 1024), requests, @@ -110,7 +121,11 @@ async function coldLoad(page: Page): Promise { fcpMs: inPage.fcp === null ? null : Math.round(inPage.fcp), lcpMs: inPage.lcp === null ? null : Math.round(inPage.lcp), clsScore: Math.round(inPage.cls * 1000) / 1000, - longTasks: { count: inPage.long.length, totalMs: Math.round(inPage.long.reduce((a, b) => a + b, 0)) }, + longTasks: { + count: inPage.long.length, + totalMs: Math.round(inPage.long.reduce((a, b) => a + b, 0)), + maxMs: Math.round(Math.max(0, ...inPage.long)), + }, titleReadyMs, jsHeapMB: Math.round((inPage.heap / 1024 / 1024) * 10) / 10, }; @@ -119,12 +134,7 @@ async function coldLoad(page: Page): Promise { async function sampleFrames(page: Page, throttle: number): Promise { const cdp = await page.createCDPSession(); await cdp.send('Emulation.setCPUThrottlingRate', { rate: throttle }); - await page.setRequestInterception(true); - page.on('request', (req) => { - const host = new URL(req.url()).host; - if (STELLAR_HOSTS.includes(host)) void req.abort(); - else void req.continue(); - }); + await blockStellar(page); await page.goto(URL_UNDER_TEST, { waitUntil: 'load' }); await page.waitForSelector('.title-screen', { timeout: 30_000 }); @@ -187,27 +197,31 @@ async function sampleFrames(page: Page, throttle: number): Promise }; } -const browser = await puppeteer.launch({ - executablePath: CHROME, - headless: true, - args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--window-size=1280,800'], - defaultViewport: { width: 1280, height: 800, deviceScaleFactor: 1 }, -}); +const browser = await launchBrowser(); +let violations: Violation[] = []; try { const page = await browser.newPage(); const load = await coldLoad(page); - console.log('COLD LOAD'); - console.log(JSON.stringify(load, null, 2)); + log('COLD LOAD'); + log(JSON.stringify(load, null, 2)); - const results: FrameReport[] = []; - for (const throttle of [1, 4]) { + const frames: FrameReport[] = []; + for (const throttle of [1, BUDGET_THROTTLE]) { const fresh = await browser.newPage(); - results.push(await sampleFrames(fresh, throttle)); + frames.push(await sampleFrames(fresh, throttle)); await fresh.close(); } - console.log('FRAMES (practice run, Stellar blocked, 12 s of play)'); - console.log(JSON.stringify(results, null, 2)); + log('FRAMES (practice run, Stellar blocked, 12 s of play)'); + log(JSON.stringify(frames, null, 2)); + + violations = checkBudgets({ load, frames }); + if (JSON_OUTPUT) { + console.log(JSON.stringify({ url: URL_UNDER_TEST, budgets: BUDGETS, load, frames, violations }, null, 2)); + } + if (violations.length === 0) log('PERF BUDGETS: all within budget'); + else for (const v of violations) console.error(`PERF BUDGET EXCEEDED: ${v.message}`); } finally { await browser.close(); } +process.exit(violations.length === 0 ? 0 : 1); diff --git a/docs/performance.md b/docs/performance.md index ff42571..b9221e5 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -8,9 +8,12 @@ Measured on the production build, in a real headless Chrome driven over CDP, so cd client bun run build bun run preview --port 4173 & -bun run perf # optionally: bun run perf http://127.0.0.1:4173/ +bun run perf # optionally: bun run perf http://localhost:4173/ +bun run perf --json > perf.json ``` +The harness exits with code 1 when a budget below is exceeded. With `--json` the report goes to stdout and the readable log to stderr, so the file stays parseable. Set `CHROME_PATH` to use a Chrome other than the default macOS install. + - **Cold load:** cache disabled, one navigation. Reports bytes by type, first contentful paint, largest contentful paint, cumulative layout shift, long tasks, and the time until the title is usable. - **Frames:** a practice run with the Stellar hosts blocked, so it is independent of the network. The script walks into the guard, fights and wanders for 12 seconds while sampling every frame, once at normal CPU and once with the CPU throttled 4 times. - **Environment:** an Apple silicon laptop, Chrome 1280 by 800 at device pixel ratio 1, local preview server. Network latency is not simulated, so absolute load times on a real connection are higher. Use the byte counts for that. @@ -40,14 +43,20 @@ bun run perf # optionally: bun run perf http://127.0.0.1:4173/ ## Budgets to keep -| Item | Budget | -|---|---| -| Cold load transfer | under 1.2 MB | -| JavaScript before the title | under 60 KB gzip | -| Largest contentful paint (local) | under 200 ms | -| Cumulative layout shift | 0.05 or less | -| Frame time, 99th percentile, 4x CPU throttle | under 20 ms | -| Long tasks during load | none over 50 ms | +The numbers live in `client/scripts/budgets.ts`, which the harness imports, so the code and this table cannot disagree. + +| Item | Budget | Enforced in CI | +|---|---|---| +| Cold load transfer | under 1.2 MB | yes | +| Cumulative layout shift | 0.05 or less | yes | +| Long tasks during load | none over 50 ms | yes | +| Frame time, 99th percentile, 4x CPU throttle | under 20 ms | yes | +| JavaScript before the title | under 60 KB gzip | no, read from the build output | +| Largest contentful paint (local) | under 200 ms | no, too noisy on shared runners | + +## Continuous integration + +`.github/workflows/perf.yml` builds the client, serves the production preview, runs the harness in a pinned Chrome and fails the check when a budget is exceeded. The failure names the metric, the measured value and the limit. The full report is uploaded as the `perf-report` artifact and summarised on the run page, so two runs can be compared. A pull request that adds a 2 MB image fails with `Cold load transfer is 3091 KB, budget is 1200 KB`. ## Known limits From f53516cc81172b87b559c017e5543321f286a054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Sat, 10 Oct 2026 12:36:35 -0600 Subject: [PATCH 02/11] fix: keyboard focus, spoken status and reduced motion gaps in the ui --- client/index.html | 12 +++++----- client/src/input/keyboard.ts | 13 ++++++++++ client/src/render/bossIntro.ts | 8 +++---- client/src/render/chrome.ts | 41 ++++++++++++++++++++++++++++---- client/src/render/descent.ts | 7 ++---- client/src/render/effects.ts | 6 ++++- client/src/render/preferences.ts | 9 +++++++ client/src/render/result.ts | 18 +++++++------- client/src/render/title.ts | 6 ++--- client/src/styles.css | 17 +++++++++---- 10 files changed, 97 insertions(+), 40 deletions(-) create mode 100644 client/src/render/preferences.ts diff --git a/client/index.html b/client/index.html index 6aea494..2f19f7b 100644 --- a/client/index.html +++ b/client/index.html @@ -16,27 +16,27 @@
-

Deepmark

-

+

Deepmark

+

- Deepmark needs a canvas-capable browser. + Deepmark needs a canvas-capable browser.
-