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.