From 919504901a7decdd00c0005663a4f8397e8b7cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 4 Sep 2026 19:27:24 +0100 Subject: [PATCH] tell agents the site can be measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine was there and nothing pointed at it, which is the same failure as a comment outliving its code: an agent would go on asking the owner to open DevTools and read numbers back, because that is the only loop it knew. bin/lighthouse.ts is the callable surface — the panel's engine with a table instead of JSON, exiting non-zero when anything is short of 100 so it can gate something without being parsed. affordances/ carries the short always-loaded note: you can measure this yourself, here is the line, do not ask a person. skills/ carries what the findings mean, why the built site and the dev server are different questions, and the three defects this has already caught — each one a rename or a setting that left something behind and produced no error. Both are read in place from the plugin directory by capabilitySources, so they arrive with the plugin and need no publish step. The invocation globs ~/officer*/platform because the checkout is officer-dev on one machine and officerdev on the other, and a doc that is right on one box and wrong on the other is worse than no doc. Co-Authored-By: Claude Opus 5 (1M context) --- affordances/fofos-lighthouse.md | 26 +++++++ bin/lighthouse.ts | 78 ++++++++++++++++++++ skills/fofos-lighthouse/SKILL.md | 120 +++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 affordances/fofos-lighthouse.md create mode 100644 bin/lighthouse.ts create mode 100644 skills/fofos-lighthouse/SKILL.md diff --git a/affordances/fofos-lighthouse.md b/affordances/fofos-lighthouse.md new file mode 100644 index 0000000..b40719f --- /dev/null +++ b/affordances/fofos-lighthouse.md @@ -0,0 +1,26 @@ +--- +name: Fofos lighthouse +description: The fofos website can be measured here — do not ask a person to run DevTools. +version: 1 +--- + +**Anything you change on the fofos website, you can measure yourself.** Both form factors, about +twenty seconds: + +```bash +# The checkout is named differently per machine, so the glob finds it either way. +bun "$(ls -d ~/officer*/platform/plugins/fofos-admin | head -1)/bin/lighthouse.ts" \ + https://web.fofosfluffybastards.com/gallery/aldi/ +``` + +Scores, the five metrics, and every failing audit **with the resources it names** — a URL and what it +cost, which is the difference between a finding and a fix. `--mobile` or `--desktop` for one, +`--json` for a script. It exits non-zero when anything is short of 100. + +**Never ask the owner to run Lighthouse in DevTools and read numbers back.** That loop is why three +real defects lived on the site for weeks. Measure, fix, measure again. + +**Always measure the BUILT site**, not the dev server — `bun run build:site` publishes to +`dist/`, and that is what visitors get. A number from `localhost:3011` is a different page. + +Read the **`fofos-lighthouse`** skill for what the findings mean and the traps in reading them. diff --git a/bin/lighthouse.ts b/bin/lighthouse.ts new file mode 100644 index 0000000..27f67ab --- /dev/null +++ b/bin/lighthouse.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env bun +/** + * Lighthouse against a URL, for an agent's shell. + * + * The same engine the panel uses — api/lighthouse.ts — with a table instead of JSON. It exists + * because the consumer is usually a terminal: somebody has just built the site and wants to know + * whether the thing they changed moved the number, and the honest answer takes twenty seconds rather + * than a round trip through a person with DevTools open. + * + * bun bin/lighthouse.ts both form factors + * bun bin/lighthouse.ts --mobile one of them + * bun bin/lighthouse.ts --json the whole result, for a script + * + * Run it from anywhere: bun resolves node_modules from this file upward, which reaches + * plugins/node_modules where the plugin's dependencies live. + */ +import { audit, type FormFactor } from '../api/lighthouse'; + +const args = process.argv.slice(2); +const url = args.find((one) => !one.startsWith('-')); +const json = args.includes('--json'); +const quiet = json || args.includes('--quiet'); + +if (!url) { + console.error('usage: bun bin/lighthouse.ts [--mobile|--desktop] [--json]'); + process.exit(2); +} + +const factors: FormFactor[] = args.includes('--mobile') + ? ['mobile'] + : args.includes('--desktop') + ? ['desktop'] + : ['mobile', 'desktop']; + +/** Lighthouse's own bands, so a number reads as good or not without looking them up. */ +const band = (score: number | null) => (score === null ? ' ? ' : score >= 90 ? ' ✓ ' : score >= 50 ? ' ~ ' : ' ✗ '); + +const results = []; +for (const formFactor of factors) { + // To stderr, so `--json` on stdout stays a clean pipe while the wait is still narrated. + results.push(await audit(url, formFactor, (line) => !quiet && console.error(` · ${line}`))); +} + +if (json) { + console.log(JSON.stringify(results, null, 2)); + process.exit(0); +} + +for (const result of results) { + console.log(`\n═══ ${result.formFactor.toUpperCase()} ${result.url}`); + + console.log( + ' ' + + Object.entries(result.scores) + .map(([name, score]) => `${band(score)}${name} ${score ?? '—'}`) + .join(' '), + ); + + console.log(); + for (const metric of result.metrics) console.log(` ${metric.label.padEnd(26)} ${metric.display}`); + + if (!result.findings.length) { + console.log('\n nothing failing'); + continue; + } + + console.log(); + for (const finding of result.findings) { + const score = finding.score === null ? ' —' : String(Math.round(finding.score * 100)).padStart(3); + console.log(` [${score}] ${finding.title}${finding.savings ? ` — ${finding.savings}` : ''}`); + // The resources, which is the difference between a finding and a fix. + for (const item of finding.items) console.log(` ${item.url || '(no url)'} ${item.detail}`); + } +} + +// A non-zero exit when anything is short of 100, so this can gate a build without being parsed. +const perfect = results.every((one) => Object.values(one.scores).every((score) => score === 100)); +process.exit(perfect ? 0 : 1); diff --git a/skills/fofos-lighthouse/SKILL.md b/skills/fofos-lighthouse/SKILL.md new file mode 100644 index 0000000..9786066 --- /dev/null +++ b/skills/fofos-lighthouse/SKILL.md @@ -0,0 +1,120 @@ +--- +name: fofos-lighthouse +title: Measuring the fofos site +description: Use when working on the fofos website's performance, accessibility, SEO or Core Web Vitals — running an audit, reading what it found, and the traps that make a number mean something other than it appears to. +version: 1 +--- + +`bin/lighthouse.ts` runs Lighthouse against a URL in a real Chrome and prints what failed, with the +resources each finding names. It is the same engine the panel uses (`api/lighthouse.ts`), so a number +you get here is the number the panel would show. + +## Running it + +```bash +# The platform checkout is named differently per machine — officer-dev here, officerdev on fofos — +# so the glob finds it either way rather than the doc being right on one box and wrong on the other. +bun "$(ls -d ~/officer*/platform/plugins/fofos-admin | head -1)/bin/lighthouse.ts" +``` + +| flag | effect | +| --- | --- | +| *(none)* | mobile and desktop, in that order — about 20 seconds | +| `--mobile` / `--desktop` | one form factor | +| `--json` | the whole result on stdout; progress goes to stderr, so the pipe stays clean | +| `--quiet` | no progress narration | + +Exit code is 0 only when every category on every form factor is 100. Non-zero otherwise, so it can +gate something without being parsed. + +## Measure the BUILT site + +The dev server at `localhost:3011` renders from the database on every request. The public site is +static files under `dist/`, served by nginx behind a proxy, with different caching, different +compression and a trailing-slash redirect. **They are not the same page** and their numbers do not +agree. + +So: `bun run build:site` first, then audit `https://web.fofosfluffybastards.com/...`. A score from the +dev server is not wrong, it is about something nobody visits. + +## Reading the output + +``` +═══ MOBILE https://web.fofosfluffybastards.com/gallery/aldi/ + ✓ performance 99 ✓ accessibility 100 ✓ best-practices 100 ✓ seo 100 + + First Contentful Paint 1.0 s + Largest Contentful Paint 2.2 s + Total Blocking Time 0 ms + Cumulative Layout Shift 0 + Speed Index 1.0 s + + [ 0] Improve image delivery — Est savings of 47 KiB + …/ALDI_LIDL_0000_1.png/960.avif 47 KiB wasted, 57 KiB served, li.w-[70%] > … > img.h-full +``` + +The bracketed number is that audit's own score out of 100. The indented line is the resource — a URL, +what it cost, and the CSS path to the element. **That last part is what makes a finding fixable**: it +tells you which block emitted it, which tells you which component to look at. + +Only scored audits appear. `informative`, `manual` and `notApplicable` are filtered out — they never +had a score, and listing them turns a report into noise nobody reads. + +## The five metrics, and which one your change moved + +Performance is computed from these and nothing else. Insights are diagnostics; they do not score. + +- **Total Blocking Time** — JavaScript. Mobile throttles the CPU 4×, so a library parsed at load + shows up here and nowhere else. Moving 56 KB of GLightbox to load-on-intent took this from 130 ms + to 0. +- **Largest Contentful Paint** — usually the biggest image above the fold. Wants three things + together: preloaded, `fetchpriority="high"`, and NOT `loading="lazy"`. Missing any one is a + separate failing audit. +- **First Contentful Paint** — the fonts, mostly. `font-display: block` holds text back until a face + arrives, so the preloads in `Layout.tsx` are what keep this honest. They went missing once and cost + 200 ms on every page but Home. +- **Cumulative Layout Shift** — anything sized after it paints. Should stay 0. +- **Speed Index** — how fast the page fills in. Follows from the other four. + +## Traps + +**A desktop number from another machine includes that machine's latency.** Auditing from officer-dev +against fofos crosses a network the owner's browser does not. Compare like with like: two runs from +the same place, before and after a change. An absolute score from somewhere else is a different +question. + +**Run-to-run variance is real** — a point or two either way on the same build. Chase a finding, not a +number. If a score moves and no finding changed, nothing happened. + +**`sizes` is not a decoration.** It tells the browser which rung to fetch before layout exists, so a +`sizes` that overstates a column downloads a file two rungs too big and looks perfect. `columnSizes` +in `components/PageSection.tsx` computes it from the same spacing the band lays itself out with, +precisely so a padding change in the Layout panel cannot silently break it. + +**A preload must name the format and the rung the `` will actually pick.** Given `href` +alone it fetches one file and the `` then chooses another — two downloads of the largest thing +on the page, which is worse than no preload. `imagesrcset`, `imagesizes` and `type` all travel +together or none of them do. + +## What this has already caught on this site + +Worth knowing, because each was invisible until something measured: + +- a `sizes` string claiming a 624px column that renders 546 — the old constant assumed the band had + no padding, so every cover fetched a rung too big +- the font preloads gone entirely, while the comment explaining them survived in `Layout.tsx` and + `build-fonts.ts` still pointed at a file that had been moved +- an LCP image left `loading="lazy"` with no preload, because a rename from `collection-cover` to + `subject-cover` missed the line that hunts for it — a string comparison the type checker cannot see + +The pattern: all three were renames or settings changes that left something behind, and none produced +an error. That is what this is for. + +## The browser + +Looked for in three places before anything is downloaded — `CHROME_PATH`, chrome-launcher's own +search, then Chrome-family binaries by name (Brave counts; it is Chromium underneath). Only if all +three miss does `@puppeteer/browsers` fetch one into `~/.cache/officer/browsers`, about 150 MB, once. + +If an audit fails with something about a browser, that chain is where to look — `chromePath()` in +`api/lighthouse.ts` narrates which one it chose on every run.