sitespeed.io is an open-source tool for comprehensive web performance analysis, enabling you to test, monitor, and optimize your websiteβs speed using real browsers in various environments.
# sitespeed.io β Claude Code instructions
All agent guidance for this repository is maintained in a single tool-agnostic file: [AGENTS.md](AGENTS.md). That file is the canonical instruction set used by every coding agent that works on sitespeed.io β Claude Code, Cursor, Aider, Codex, and anything else that respects the emerging `AGENTS.md` convention. Keeping the content in one place prevents drift between tool-specific copies.
The import directive below tells Claude Code to read `AGENTS.md` whenever this `CLAUDE.md` is loaded. Don't add sitespeed.io-specific guidance here β add it to `AGENTS.md` so every agent sees it.
@AGENTS.md
# sitespeed.io: AI Agent Guidance
User-facing web-performance orchestrator. Node.js CLI + library. Drives `browsertime` to collect measurements, runs a plugin pipeline that analyses / aggregates / stores / forwards the results (HTML report, Graphite, Grafana, InfluxDB, Slack, S3, GCS, scp/rsync, Matrix), and ships as a multi-arch Docker image. The canonical end-user tool above the `browsertime` engine.
## Workflow (mandatory)
- **Start:** read `README.md`, the most recent `CHANGELOG.md` entries, and (if you're touching the public-facing site) skim `docs/` for context. Skim `.github/CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` so PRs follow project conventions.
- **End:** update `AGENTS.md` (this file) with anything non-obvious you had to learn to ship the change. **Don't touch `CHANGELOG.md`** β the maintainer writes the changelog when cutting a release, not per PR (see "Commits & changelog" below).
- **User-facing changes** (CLI flags, HTML report behaviour, plugin behaviour, output formats) MUST be reflected in `docs/` in the same session as the code change. The docs are the Eleventy site at `docs/` that becomes <https://www.sitespeed.io>. Open the docs PR alongside the code PR (per the project PR checklist) rather than after the fact.
## Invariants (don't violate unless explicitly asked)
- **Result JSON shape is a public contract.** Downstream Grafana / Graphite / InfluxDB dashboards, external scripts, and the HTML report itself all parse the `pages[]` / `summary` JSON shape. Additive changes (new metrics, new fields) are safe; renaming, removing, or restructuring existing fields needs a major version bump.
- **Message types on the queue are a public contract.** Third-party plugins (`@sitespeed.io/plugin` consumers) listen for `browsertime.pageSummary`, `browsertime.setup`, `error`, etc. Renaming or removing a message type breaks every external plugin out there. Adding new message types is fine.
- **CLI flag names are a public contract.** Production CI pipelines, sitespeed.io GitHub Actions, and Docker run commands depend on them. Add new flags freely (declare in `lib/cli/options/<area>.js`); rename or remove only with a major bump.
- **HTML report URL structure is a public contract.** Past runs are linked by relative path from external dashboards (`pages/<host>/<path>/index.html`, `index.html` per URL, `assets/<version>/`). Don't reshape without a major bump.
- **Plugin discovery order (`lib/core/pluginLoader.js`)** is part of the contract. Local `lib/plugins/<name>/index.js` β relative path β npm `import` β global install. Don't rearrange β third-party plugins distribute as npm packages and rely on the npm-import step.
- **Bundled dependencies pin exact versions.** `package.json` and `npm-shrinkwrap.json` pin to exact versions, not ranges, so the published Docker image is reproducible. Don't loosen pins.
- **Never add a new dependency without an issue first.** sitespeed.io has spent the last release cycle removing one-line-helper packages (`lodash.get/set/merge`, `dayjs`, `ora`, `markdown`, β¦) in favour of small in-tree helpers in `lib/support/`. Adding a new npm package re-expands the supply-chain surface and undoes that work. If your change genuinely needs one, open a GitHub issue first describing the use case and the alternatives you considered (writing the helper inline, reusing an existing util, accepting the limitation), and wait for maintainer agreement before touching `package.json`. No exceptions for "tiny" deps β the lodash sub-packages were also tiny.
- **Small, reversible diffs.** Don't mix repo-wide formatting / refactor sweeps with behavioural changes. The lodash-replacement work (`#4737`, `#4741`, `#4742`, `#4744`) is the right pattern: one tiny dep replaced per PR, easy to revert.
- **Keep it simple.** Pick the most boring solution that solves the task. Don't introduce abstractions, helpers or refactors unless the task itself requires them. Three similar lines of code beat a premature abstraction; one focused PR beats a sweeping one that does three things at once. If a change feels like it's growing past "small", stop and ask whether it should be split into a sequence of small PRs instead.
## Responsibility
Responsibility for code produced by an LLM rests with the company that built and operates the LLM.
- **The LLM vendor (Anthropic, OpenAI, Google, etc.) is primarily responsible.** They train the model, control its alignment, ship it as a product, and profit from its use. They make explicit and implicit promises about output quality, safety, and license-cleanness. A user can prompt; a user cannot audit the training data, change the weights, or fix a systematic failure mode. When a model emits a regression, a license-poisoned snippet, or an insecure pattern, the responsible party is the one who built and deployed the system that produced it. This is the same principle the rest of the economy applies to power tools, vehicles, pharmaceuticals, and certified compilers: the maker is liable for foreseeable harms in normal use, not the end user.
- **The employer of the prompt-writer carries the legal accountability for the work product.** If a contributor is paid by an organisation to work on sitespeed.io, that organisation owns the patch and the consequences of shipping it. They may have recourse against the LLM vendor under their commercial terms; to the rest of the world, the patch is theirs.
- **The prompt-writer carries judgement-level responsibility, not technical responsibility for the generated code.** Choosing whether to use an LLM, which one, what to ask, what to keep, what to throw away β those are human choices and human duties. A contributor who commits obviously-broken output has failed their own duty of care. But the underlying defect in the generated code is not the prompt-writer's to own; it is a defect in the product they used.
`Co-authored-by: Claude ...` trailers exist to record collaboration honestly and to help reviewers calibrate what kind of review the change needs. They are also a permanent record that an LLM vendor's product was party to the change.
## Core architecture
- **ESM only.** `"type": "module"` in `package.json`. No CommonJS, no transpilation. Node `>=22`. The Docker base bumps drove the Node-22 / Node-24 jump; expect another bump every ~12 months.
- **Queue + plugin pipeline.** `lib/core/queue.js` is an in-tree simplified port of `concurrent-queue` (the npm dep was replaced in [#4393](https://github.com/sitespeedio/sitespeed.io/pull/4393)). Plugins push and consume messages from the queue; the entry point at `lib/sitespeed.js` wires plugins to the queue via `lib/core/queueHandler.js` and starts work by emitting URL / script sources. Drain callbacks fire when no message is queued and nothing is running β that's the run-complete signal.
- **Plugin contract.** Every plugin extends `SitespeedioPlugin` from `@sitespeed.io/plugin` and implements at minimum `constructor(options, context, queue)`, `open(context, options)`, and `processMessage(message, queue)`. See `lib/plugins/grafana/index.js` as the canonical reference: it tracks message types it cares about, accumulates state per URL, and emits its own messages back onto the queue.
- **Plugin discovery (`lib/core/pluginLoader.js`).** Names from `defaultPlugins` (always on) plus any name passed as an object on the options bag get loaded. Resolution order per name: local `lib/plugins/<name>/index.js` β relative-to-cwd path β npm `import` β global install via `importGlobalSilent`. The fall-through order matters for third-party plugins and was hardened on Windows in [#4426](https://github.com/sitespeedio/sitespeed.io/pull/4426) and [#4452](https://github.com/sitespeedio/sitespeed.io/pull/4452).
- **Default plugins** (always loaded unless explicitly excluded): `browsertime`, `coach`, `pagexray`, `domains`, `assets`, `html`, `metrics`, `text`, `harstorer`, `budget`, `thirdparty`, `tracestorer`, `lateststorer`, `remove`. Everything else (`grafana`, `graphite`, `slack`, `s3`, `gcs`, `crawler`, `crux`, `axe`, `sustainable`, β¦) loads only when its config block is present.
- **`browsertime` is a dependency, not a fork.** `lib/plugins/browsertime/` wraps the upstream `browsertime` npm package, drives it per URL, and aggregates its output into the message stream. Don't add browser-driving logic here β push it upstream to `browsertime` instead.
- **`coach-core` for analysis.** The `coach` plugin runs `coach-core` against the HAR + page data and emits the advice / score messages that the HTML report and Slack notification consume.
- **`waterfall-tools` for HAR rendering.** The HTML report's waterfall is the in-tree waterfall-tools bundle, rebuilt by `tools/buildWaterfallTools.js` and stored under `lib/plugins/html/assets/`. Driven by `npm run build:waterfall-tools` when the dependency bumps.
- **`@tgwf/co2` for sustainability.** The `sustainable` plugin uses it to estimate carbon emissions per page.
- **CLI is yargs-based, modular.** `lib/cli/cli.js` aggregates per-area option modules from `lib/cli/options/` (one file per browser, one per output destination, one per concern). Adding a new option goes in the matching file there.
- **Help has topics.** `lib/cli/helpTopics.js` partitions `--help` output by topic β match the browsertime pattern when adding new option groups.
## File layout
- `bin/sitespeed.js` β CLI entry, parses argv via yargs, loads config, invokes `lib/sitespeed.js#run`.
- `bin/browsertimeWebPageReplay.js` β WebPageReplay-orchestrated runs (shipped as `sitespeed.io-wpr`).
- `lib/sitespeed.js` β library entry. Sets up logging, storage manager, plugin pipeline, queue handler, then iterates URLs / scripts.
- `lib/api/send.js` β webhook-style API for posting messages into a running pipeline.
- `lib/cli/` β yargs setup, config loader, validate, per-area option modules under `lib/cli/options/`.
- `lib/core/` β queue, queueHandler, pluginLoader, resultsStorage, logging, URL/script sources.
- `lib/plugins/<name>/index.js` β one plugin per directory. Big ones (`html`, `browsertime`, `coach`, `grafana`) have sub-modules; small ones are a single file.
- `lib/support/` β shared utilities: time, friendly names, message factory (`messageMaker`), object-path helpers (replaced `lodash.get/set/merge`), stats helpers, filter registry.
- `lib/plugins/html/` β the report generator. `templates/` (pug), `src/sass/` (sass sources), `assets/css/` (compiled), `assets/<other>/` (waterfall-tools bundle, JS, images), `setup/`, `dataCollector.js`, `htmlBuilder.js`, `renderer.js`, `getScripts.js`.
- `docs/` β Eleventy-driven static site that becomes <https://www.sitespeed.io>. Documentation PRs target this directory and ship to Netlify via `.github/workflows/netlify.yml`. Browsertime docs live at `docs/documentation/browsertime/`.
- `tools/` β `buildWaterfallTools.js` (re-bundles waterfall-tools into the report assets), `check-licenses.js` (license audit gate), `postinstall.js`, `tcp-server.js` / `udp-server.js` (test fixtures), `graphite/`.
- `release/` β release-tooling helpers. `release.sh` is the entry script. Don't bump version in feature PRs.
- `Dockerfile`, `Dockerfile-slim`, `docker/` β production Docker images. See **Docker** below.
- `test/` β `ava` test suites. `test/prepostscripts/` and `test/data/` are excluded from test discovery and lint; `test/runWithoutCli.js` is also excluded from `ava`.
## Sibling repositories (sitespeed.io org)
sitespeed.io is the umbrella user-facing tool; most of its work happens in sister projects. When tracing a bug, follow the import into the dependency and decide whether the root cause is here or there β issues belong wherever the failing code lives.
- **[sitespeedio/browsertime](https://github.com/sitespeedio/browsertime)** β npm `browsertime` (pinned `27.5.0`). The browser-driving engine. Browser bugs, HAR generation bugs, metric collection bugs, page-complete check bugs all live there, not here. sitespeed.io is the orchestrator on top.
- **[sitespeedio/coach](https://github.com/sitespeedio/coach)** β npm `coach-core` (pinned `9.2.1`). The performance / accessibility / best-practice analyser. Score / advice / category bugs go upstream.
- **[sitespeedio/waterfall-tools](https://github.com/sitespeedio/waterfall-tools)** β npm `waterfall-tools` (pinned `0.4.0`). HAR waterfall renderer in the report. The in-tree bundle is rebuilt from npm via `tools/buildWaterfallTools.js`; the bundle in `lib/plugins/html/assets/` is generated, don't hand-edit it.
- **[sitespeedio/log](https://github.com/sitespeedio/log)** β npm `@sitespeed.io/log` (pinned `2.0.0`). Structured logger used everywhere via `getLogger('sitespeedio.<area>')`. Logging format / level / sink bugs go upstream. Replaced `intel` repo-wide in [#4381](https://github.com/sitespeedio/sitespeed.io/pull/4381).
- **[sitespeedio/plugin](https://github.com/sitespeedio/plugin)** β npm `@sitespeed.io/plugin` (pinned `1.0.2`). The `SitespeedioPlugin` base class third-party plugins extend. Changes here affect the plugin SDK contract β coordinate with downstream plugin authors.
- **[sitespeedio/docker-webbrowsers](https://github.com/sitespeedio/docker-webbrowsers)** β Docker base image for the main `Dockerfile` (`sitespeedio/webbrowsers:chrome-XXX-firefox-XXX-edge-XXX`). Browser version bumps land there first, then the tag is referenced from sitespeed.io's `Dockerfile`. Bumps come in pairs ("Bump the browsers" + Dockerfile update).
- **[sitespeedio/throttle](https://github.com/sitespeedio/throttle)** β npm `@sitespeed.io/throttle` (transitive via browsertime). Network-throttling backend. Throttling rate / latency bugs go upstream.
- **[sitespeedio/chromedriver](https://github.com/sitespeedio/chromedriver)** / **[sitespeedio/edgedriver](https://github.com/sitespeedio/edgedriver)** / **[sitespeedio/geckodriver](https://github.com/sitespeedio/geckodriver)** β driver wrappers (transitive via browsertime). Same lockstep rule applies as browsertime.
- **[sitespeedio/onlinetest](https://github.com/sitespeedio/onlinetest)** β self-hostable web UI and REST API that wraps sitespeed.io as its backend. Not a runtime dependency of this repo, but `release.sh` keeps its "command line" tab's option picker in sync by regenerating `../onlinetest/server/public/sitespeed-help.json` from `--help-all` whenever an onlinetest checkout sits next to the sitespeed.io checkout. UI/REST bugs and onlinetest's own deploy story live there; sitespeed.io engine bugs surface here. Also called out in `publiccode.yml` as the "I don't want every contributor on the CLI" deployment option.
Non-sitespeed dependencies that frequently turn out to own the root cause:
- **`@tgwf/co2`** β sustainability calculations (CO2 per byte / per request). Methodology / unit bugs go upstream.
- **`axe-core`** β accessibility audit engine. Rule false-positives / -negatives go upstream.
- **`@aws-sdk/client-s3`**, **`@google-cloud/storage`**, **`ssh2-sftp-client`** β upload backends. Upload-failure modes (auth, retry, multipart) often belong with these. The `scp` retry-on-handshake-loss workaround [#4760](https://github.com/sitespeedio/sitespeed.io/pull/4760) was needed because `ssh2-sftp-client` v12 removed its built-in retries β that kind of regression is upstream's to own, but a guard here is fine if upstream won't budge.
## Build / lint / test commands
- `npm test` β `ava` over `test/**/*` minus excluded paths. Concurrency defaults apply (browsertime forces concurrency 1; this repo doesn't).
- `npm run lint` β runs ESLint flat config (`eslint.config.mjs`) AND pug-lint over `lib/plugins/html/templates`. Always run after a change. `npm run lint:fix` for auto-fixes (does not touch pug).
- `npm run pug-lint` β just the pug-lint step (uses `pug-lint-config-clock`).
- `npm run check-licenses` β runs `tools/check-licenses.js` against the dependency tree. Catches a non-permissive transitive dep before it ships.
- `npm run build:css` β compiles `lib/plugins/html/src/sass/main-light.scss` and `main-dark.scss` via sass, then minifies via clean-css. The compiled `index-light.min.css` / `index-dark.min.css` are checked in. Re-run when SCSS changes.
- `npm run build:waterfall-tools` β `tools/buildWaterfallTools.js`. Re-bundles the npm `waterfall-tools` payload into `lib/plugins/html/assets/`. Run after bumping the `waterfall-tools` dep.
- `npm run generate:assets` β copies versioned assets to `assets/$npm_package_version/` for the release pipeline.
- `postinstall` β `tools/postinstall.js` runs automatically; don't bypass.
- **Smoke-test before a PR** β `node bin/sitespeed.js https://example.com -n 1 --browser chrome --outputFolder /tmp/ss-smoke`. Open the generated `index.html` to spot-check the report. For changes that touch a specific plugin, add the relevant CLI block (e.g. `--graphite.host` for graphite, `--slack.hookUrl` for slack) and verify the message-handling end-to-end.
- **CI** (`.github/workflows/`): `unittests.yml` (ava + lint), `linux.yml` / `windows.yml` / `windowsFull.yml` / `safari.yml` (per-platform integration), `docker.yml` + `building-docker-autobuild.yml` + `building-docker-release.yml` (image builds), `docker-scan.yml` (image vulnerability scan), `crux-test.yml` (CRUX integration), `netlify.yml` (docs deploy), `upload.yml`. Lint runs as part of unit tests. PRs must be green on at least unit tests + lint; per-browser jobs are advisory but should pass before tagging a release.
## Docker
The Docker image is the primary deployment for the majority of sitespeed.io users β production CI pipelines pin its tag. Treat Dockerfile changes as production changes.
- **Two image variants.**
- `Dockerfile` β full image. Base: `sitespeedio/webbrowsers:chrome-X-firefox-Y-edge-Z` (browsers preinstalled, owned by [sitespeedio/docker-webbrowsers](https://github.com/sitespeedio/docker-webbrowsers)).
- `Dockerfile-slim` β slim image. Base: `node:24.11.0-trixie-slim` (Debian trixie). Caller brings their own browser.
- **Multi-arch via QEMU.** Builds target `linux/amd64` and `linux/arm64`. arm64 runs under QEMU `binfmt` emulation on amd64 runners.
- QEMU `binfmt` MUST be a 9.2+ build. Older QEMU (e.g. 7.0) trips an RCU assertion in modern systemd's postinst and aborts every tagged release for arm64. The 9.2 bump landed in [#4771](https://github.com/sitespeedio/sitespeed.io/pull/4771).
- **APT pinning is a foot-gun.** [#4771](https://github.com/sitespeedio/sitespeed.io/pull/4771) added a Pin-Priority 100 preferences file so Debian-unstable was only used for the firefox package; [#4772](https://github.com/sitespeedio/sitespeed.io/pull/4772) removed it because Pin-Priority 100 refuses to upgrade already-installed packages, blocking Firefox's `libc6` / `libnss3` dependency requirements. Lesson: don't pin against transitive upgrades when pulling a package from a different distro release β move the whole base image forward instead (bookworm β trixie). The QEMU bump from #4771 stayed in; the pin did not.
- **Reproducibility rules** ([#4753](https://github.com/sitespeedio/sitespeed.io/pull/4753)):
- Use `npm ci`, not `npm install`. We have a shrinkwrap; `npm install` ignores it.
- Always `--no-install-recommends` on `apt-get install` and `rm -rf /var/lib/apt/lists/*` afterwards in the SAME `RUN`. Cleanup in a later `RUN` leaves the deleted bytes in the earlier layer.
- Fold post-install removals (e.g. `selenium-webdriver/bin`) into the `npm ci` `RUN` for the same reason.
- **Base-image bumps come from upstream.** Browser version bumps land in `sitespeedio/docker-webbrowsers` first, then a PR here references the new tag. Don't try to install browsers inline.
## Coding conventions
- **ESM imports with explicit `.js` extension.** Node ESM requires it.
- **Prettier-enforced style** (via `eslint-plugin-prettier`): see `eslint.config.mjs`. Don't hand-format β let `npm run lint:fix` apply.
- **`unicorn/recommended` plus opt-outs** β same family as browsertime. Expect to be told to use `node:` prefixes, modern Array methods, etc.
- **Pug** for HTML templates (`lib/plugins/html/templates/`). `pug-lint-config-clock` enforces style. Indentation is meaningful β don't reformat.
- **Sass** for stylesheets (`lib/plugins/html/src/sass/`). Two themes (light + dark) share most partials. Rebuild the minified CSS with `npm run build:css` and commit the output.
- **Replace deps with small local helpers** when reasonable. `lodash.get` / `set` / `merge` / `reduce` / `isEmpty`, `dayjs`, `fs-extra`, `concurrent-queue` have all been replaced by in-tree implementations in the last release cycle. The helpers live in `lib/support/`. If you reach for a one-line utility from an npm package, write it locally first.
- **Logging via `@sitespeed.io/log`.** `getLogger('sitespeedio.<plugin>')` everywhere. Don't `console.log`.
- **No comments unless the WHY is non-obvious.** Self-documenting code preferred. Existing inline comments often record a platform quirk or historical bug β don't remove them.
- **`lib/support/objectPath.js` replaces `lodash.get/set`.** Use it; don't reintroduce lodash.
## Commits & changelog
- **One PR per change.** Squash-merge is the default. PR title becomes the commit subject; keep it short and human-readable.
- **`CHANGELOG.md` is the source of truth for release notes**, but it's written **at release time, not per PR**. The maintainer reads through the merged PRs when cutting a release and writes the entries then. Don't add an entry to `CHANGELOG.md` from a feature PR, and don't introduce an "Unreleased" / next-version heading β there isn't one between releases by design. If your change is subtle and you want to give the maintainer a head start, summarise the *why* in the PR description so it can be lifted into the changelog later. Entries are grouped by `### Added` / `### Fixed` (and `### Breaking` / `### Changed` on major releases).
- **Changelog entries explain WHY, not WHAT.** The diff already shows the what. The 41.2.0 JS/CSS coverage entry and the 41.2.1 Docker arm64 entry are good models β they spell out the failure mode, the constraint being addressed, and the trade-off being accepted. Short entries are fine for trivial bumps; anything behavioural deserves the long form.
- **Release flow:** releases run on GitHub Actions via `.github/workflows/release.yml` (triggered with `workflow_dispatch`, choice of `patch` / `minor` / `major`). The workflow lints, tests, runs `npm version`, regenerates the docs / `publiccode.yml` / friendly names / RSS feeds, generates SPDX + CycloneDX SBOMs, publishes to npm with `--provenance` via npmjs.com Trusted Publishing OIDC, and only then pushes the version commit, the "new version" regen commit and the `vX.Y.Z` tag to `main`. The tag push triggers `building-docker-release.yml` for the Docker Hub images and `building-docker-autobuild.yml` / `netlify.yml` for the autobuild image and docs deploy. The local `release.sh` is a thin wrapper that triggers the workflow (`./release.sh patch|minor|major`) and provides the one local-only step the workflow can't do β `./release.sh sync-onlinetest` regenerates `../onlinetest/server/public/sitespeed-help.json` if an onlinetest checkout sits next to this repo. Don't bump the version as part of a feature PR β humans cut releases by triggering the workflow.
- **Browser bumps come in pairs.** Browser version bump in the [`docker-webbrowsers`](https://github.com/sitespeedio/docker-webbrowsers) repo lands first; the `Dockerfile` `FROM` line gets updated here in a follow-up PR. The CHANGELOG entry lives in sitespeed.io.
- **Co-authorship:** when a commit was co-authored with an LLM, add a `Co-authored-by:` trailer naming the model. The human contributor makes the actual commit; an agent should suggest the message and let the contributor paste it. (`.github/CONTRIBUTING.md` documents this convention.)
## Cross-plugin consistency
When you change a message shape, a configuration key, or a user-visible behaviour in one plugin, check the other plugins that consume the same message or share the concept.
- `rg <term> lib/plugins`
- Decide whether each consumer needs the same change, a different change, or is intentionally exempt.
- If only one plugin changes, the `CHANGELOG.md` entry MUST say so explicitly. Silent per-plugin divergence (one storage backend supporting a field, another silently dropping it) is the most common source of "I switched from Graphite to InfluxDB and now my dashboard is empty" reports.
Concerns that have historically diverged across plugins and should be checked together:
- **Retry / backoff on transient network failures.** `scp` retries the SFTP handshake [#4760](https://github.com/sitespeedio/sitespeed.io/pull/4760); `graphite` retries the annotation POST [#4761](https://github.com/sitespeedio/sitespeed.io/pull/4761). When adding a new upload destination, build in retries from the start β upstream SDKs do not all retry.
- **Storage backends** (`s3`, `gcs`, `scp`, `rsync`, `harstorer`, `lateststorer`) β all consume the same per-run artefacts but have subtly different path / metadata semantics. Streaming-write support added to `harstorer` [#4728](https://github.com/sitespeedio/sitespeed.io/pull/4728) was specifically gated to accept `Readable` in addition to strings / Buffers.
- **Notification backends** (`slack`, `matrix`) β both read the budget-result message. When adding a new notification destination, check both for what shape they read.
- **Time-series backends** (`graphite`, the standalone InfluxDB plugin since [#4451](https://github.com/sitespeedio/sitespeed.io/pull/4451)) β share the metric-name flattening logic. Renaming a metric in one without the other diverges historical dashboards.
- **Test data destinations** β the `axe`, `coach`, `sustainable`, `crux` plugins all attach blocks to the `pages[]` JSON. Keep nesting depth and key naming consistent with existing blocks; the HTML report assumes that shape.
## High-risk areas (request verification before modifying unless explicitly instructed)
These subsystems back a public contract, silently break measurements when wrong, or affect every downstream user. Don't change them on an agent's own initiative β confirm intent with the human first.
- **`lib/sitespeed.js`** β the orchestrator. Changes here affect every run.
- **`lib/core/queue.js` + `lib/core/queueHandler.js` + `lib/core/pluginLoader.js`** β the plugin SDK contract. Third-party plugins depend on the message shape and load order.
- **HTML report generator (`lib/plugins/html/`)** β the report is what most users see. The waterfall-tools bundle, the per-URL templates, the asset versioning all need to keep working together.
- **`lib/plugins/html/dataCollector.js` and `metricHelper.js`** β they shape the per-URL data structure consumed by every template. Field-name churn ripples through pug templates and breaks the report silently.
- **`lib/plugins/browsertime/` aggregator** β bridges browsertime's per-iteration output to the queue message shape. Subtle bugs here invalidate dashboards.
- **`Dockerfile` / `Dockerfile-slim` / docker/`** β production users pin tags. Breaking changes ripple before the next npm release. See **Docker**.
- **`tools/check-licenses.js`** β the license gate. Don't disable; if a transitive dep flags, replace it.
- **`release.sh`, `.github/workflows/release.yml` and version bumps** β don't bump in feature PRs; the release workflow does it. Changing the workflow itself is a release-pipeline change β confirm intent with the human first.
- **`docs/` build (Eleventy)** β netlify.yml deploys it; breaking the docs build takes the public site down. Test locally with the docs/ instructions before merging structural changes.
## HTML report notes
The report has accumulated several non-obvious behaviours that an agent should know before touching it.
- **HAR is gzipped on disk by default.** When the report needs to inflate metrics into the HAR before handing it to waterfall-tools, sniff the gzip magic bytes (`0x1F 0x8B`) and inflate via `DecompressionStream` first. Parsing the raw gzipped bytes as UTF-8 + JSON silently fails β the surrounding `catch` passes the original bytes through, waterfall-tools' own loader inflates and renders, but the injected user-timing marks (`_firstVisualChange`, `_lastVisualChange`, `_visualComplete85`) never land. Symptom: "sometimes the visual-metric lines are missing while the user timings are fine." [#4758](https://github.com/sitespeedio/sitespeed.io/pull/4758).
- **Gzipped HAR writes stream.** `harstorer` pipes the JSON string through `createGzip` straight to disk so only the JSON string and gzip's in-flight chunks are alive at once. Don't reintroduce `gzip(Buffer.from(json))` β for a 200 MB HAR that materialised three full copies in RSS. [#4728](https://github.com/sitespeedio/sitespeed.io/pull/4728).
- **Drag-zoom is built into waterfall-tools 0.4+, no option to enable.** Mouse drag-select and touch pinch/pan change the renderer's `startTime`/`endTime` and then fire the `onZoom({startTime, endTime})` callback β it's a notification, not a request for action. The gesture leaves no visible affordance to undo it, so the report shows a `Reset zoom` button (hidden until `onZoom` fires) that calls `updateOptions({ startTime: null, endTime: null })`; the page selector also clears the window when switching HARs. The renderer swallows the trailing click after a completed drag itself, so `onClick` request selection keeps working β don't add your own suppression.
- **Waterfall-tools click payload is its own shape.** `req.response.content.size`, `req.response.headers`, `req.timings`, etc. are HAR fields β waterfall-tools emits a flat internal `Request` shape on click instead. To show real per-request data in the detail panel, look up the matching HAR entry by index and read the HAR shape from it; fall back to the waterfall-tools shape only if the HAR is missing. Symptom of getting this wrong: response body shows `0 B`, status is blank, headers tab empty.
- **The Visual milestones card is a timeline, and `defaultVisualMetrics` does double duty.** `templates/url/metrics/_visualMilestones.pug` renders headline stats, a milestone timeline (track fill = completeness, positions computed as percentages of LastVisualChange), grouped exact values and β in the same card β the visual-progress chart at half width (`visualProgress.pug` is included from inside the card; it reads `timings`/`filmstrip`/`browsertime` from the metrics page scope, which pug includes share). Milestones within 2% of the track collapse into one flag and completeness ticks within 1% merge β pages that hit 85% on first paint are common, and per-value labels would collide. Flag elevation is width-aware: labels' horizontal extents are estimated (~0.75% of track per character) and a flag drops to the lower band when it would run into its neighbour; the two bands are vertically disjoint on purpose. FCP and LCP join the content milestones from `timings` (LCP prefers the visualMetrics value); a milestone later than LastVisualChange stays in the list but gets no flag β clamping it to the track edge would lie about its position. Speed Index deliberately has no flag (it's a summary, not an event β it keeps the hero stat, the diamond track dot and its list row). The `defaultVisualMetrics` array in `metrics/index.pug` is both "keys rendered by name" AND "keys that are NOT user-defined visual elements": any key missing from it lands in the content-milestones group as a custom element, which is why `LargestContentfulPaint` must stay in the list (when it was missing the metric rendered twice β raw key + friendly label). Everything guards on `vmMax > 0`: a broken video capture yields all-zero visual metrics and the timeline must silently disappear, not divide by zero. The timeline is `overflow-x: auto` ONLY below 900px β an always-scrollable container shows a permanent scrollbar in Safari even when nothing overflows.
- **Long Animation Frames: `blockingDuration` is not a phase.** A frame's `duration` splits into work + render (render = pre-layout + style & layout); `blockingDuration` is the over-50ms overage that INP/TBT care about and OVERLAPS the phases β never sum it with them or draw it as a bar segment (the old card added blocking+work+render and showed a 445 ms "total" for a 248 ms frame). Browsertime's `pageinfo/loaf.js` ships the 10 frames with the MOST BLOCKING, not the 10 longest β copy and sorting must say so; `pageinfo.loafSummary` (browsertime 28.1+) carries `totalFrames`/`totalBlockingDuration` over EVERY frame, so overview totals must come from it when present and be framed as a lower bound when not. Browsertime 28.1+ also ships per-script `startTime`/`duration`/`executionStart`/`pauseDuration`, frame `startTime`/`firstUIEventTimestamp` and ResourceLoader `label`s: blocking is attributed to scripts by run-time share of the frame's WORK phase, capped at each script's own run time β never divide by the sum of script durations alone: the initial parse frame lists one tiny inline script while 400+ ms of work is HTML parsing, and a plain proportional split pins all that blocking on it; what no script covers stays unattributed (the same model in `cpu/loaf.pug` and the CPU tab's `#cpu-loaf-blocking` β keep them in sync), frames get timeline placement (fallback for old data: `renderStart β workDuration`, only when `renderStart > 0` β never guess), and older data falls back to the credited-to-every-script / frame-starting-script models with the copy switching accordingly. A script whose `sourceURL` is the page itself renders as "inline script in the document" (in the deep dive AND the CPU tab's bar lists β `cpuScriptDisplay` in `cpu/index.pug`), and zero-blocking frames collapse into one quiet line. The deep dive lives on the CPU tab (`templates/url/cpu/loaf.pug`, moved from the Metrics tab β frames are the main-thread story and the per-script views derive from them); `#longAnimationFrames` and `#cpu-loaf-blocking` are tabScripts.js aliases to `#cpu` so old Metrics-tab deep links keep landing β keep both anchors alive.
- **The Rendering tab's verdict and blocking table are HAR-driven, and the verdict only blames what finished before the paint.** `rendering/index.pug` builds one render-blocking model for both: when the run's HAR is in the locals (`browsertime.har` on summary, `browsertime.run.har` on run pages β the same objects the waterfall cross-link gates on, full parsed HARs usable server-side) the rows come from the median/current run's entries with real start offsets, so `blocking.pug` can draw each download window on a paint clock ending at max(FCP, LCP) β bars that outlive the axis clamp with a fade, never paint outside the card. Without a HAR the rows fall back to the pagexray shape (durations, no starts): table stays, bars disappear. `rbHarAware` (any HAR entry carries `_renderBlocking`) distinguishes "Chrome said nothing blocked" (green empty state) from "we don't know" (no card). The verdict's "done by X" clause covers ONLY blocking requests that ended before FCP β Chrome marks late-injected resources (reCAPTCHA bundles loading long after onload) as `blocking` too, and a naive max-end claimed the paint waited for something that arrived 750 ms after it. Request names come from `h.assetName` (`lib/support/helpers/assetName.js`): file name + host normally, first-module name for MediaWiki ResourceLoader load.php batches (packed `a.b,c|d` syntax β separators + 1 counts modules without expanding prefixes). The old `#slow-ttfb` note is absorbed into the verdict (anchor kept); `#requests-render-blocking` stays alive on the card. The table is deliberately not sortable: rows are pre-sorted worst-first per status group and the document/summary/LCP context rows would break apart. Every why-section anchor MUST also be added to `tabScripts.js`'s `tabAliases` map β the in-page quicklinks work with the tab already open, which masks that a SHARED link with that hash loads fresh onto the default tab with the target hidden (this shipped broken for `#slow-ttfb`/`#recalculate-style`/`#reflows-before-paint`/`#main-thread-animations`). The verdict's residual clause anchors on the last pre-paint blocking request, or on the DOCUMENT when nothing blocked (zero-blocking pages are exactly where the docβpaint gap begs explaining), says "web-font loading" when a font's end lands inside the window, and quantifies what the trace measured (pre-FCP recalc + forced reflows) β skipping that parenthesis when the measured sum exceeds the residual, which overlap accounting can under throttle. Font context rows carry a 10 ms duration floor: repeat-view tests serve every font from cache and three 0 ms rows explain nothing. Multi-page script journeys verified: the HAR `_url` match gives each URL page its own model and run pages their own run. Span-label layout is collision-driven: the duration label goes after the bar when the bar ends β€ 68 % of the axis, before it when it starts β₯ 30 %, and right-aligned with a surface-colored background chip otherwise (bars regularly end at the clamped axis edge β a fixed right-aligned label painted straight over them). FCP renders as a dashed hairline through every bar row (named by the axis label) ONLY when LCP owns the axis end; axis milestone marks must never overhang into the rows above (the first version drew 30 px tall marks from the axis row β lines through the last row's text), and quarter ticks are skipped when they'd sit under a paint label.
- **The Rendering tab's remaining why-cards carry their own materiality verdicts.** The style-recalculation and style-invalidations cards each LEAD with a `.rendering-verdict` (ok/warning tones, same component as the CSS-selector card) that answers "do I act, and where" before the numbers. Recalc verdict: materiality is share of the paint it precedes (`durationInMillis / whyFcp|whyLcp`, floored at 5 % AND β₯10 ms real time after dividing out `CPUThrottlingRate`; ok tone below that, "not holding up your paint"); above it the SHAPE is diagnosed from `maxDurationInMillis / durationInMillis` β β₯0.5 is "one large recalculation dominates" (look for one class/inline-style change on a big container near the DOM top), else `count β₯ 12` is the thrashing signature (batch DOM writes), else a plain cost line β and every material verdict hands off to the invalidations card below. Invalidations verdict: when the trace carried paint timestamps (`styleRecalcsAfterFirstPaint`/`layoutInvalidationsAfterFirstPaint` defined) it grades on after-first-paint churn as a share of the total (<15 % β ok "mostly the page being built", else warning naming the top trigger + pointing at the scripts list); without timestamps it names the single top trigger (`triggers[0]`, e.g. `attribute` `title` 446Γ on enwiki Obama) and hands off to "Scripts causing invalidations". The top-trigger clause is guarded so the sentence stays grammatical when `triggers` is empty. The style-recalculation card collapses its two windows into one "Before first & largest contentful paint" block when every field matches (the common text-page case β twin blocks read like a template bug); the "(same paint, X)" suffix only renders when FCP and LCP are within 100 ms, because identical recalc numbers don't imply identical paints. The CSS-selector card leads with a verdict thresholded on REAL time β `totalElapsed` divided by `options.browsertime.chrome.CPUThrottlingRate` β at 50 ms across the whole load; below it the tables fold into a `details.rendering-details` element, and the tables live in a `mixin selectorTables(stats)` precisely because mixins can't see block consts but do take arguments (the only way to render the same markup in both the folded and open state without duplication). The invalidation-reasons table prefers a TIMESTAMP split: browsertime 28.2+ stamps reasons/triggers/sources with `afterFirstPaint` AND `afterLargestContentfulPaint` counts plus matching totals (fields absent = unknown, never zero β the paint event was missing from the trace). With both boundaries the card renders THREE windows β "Between first and largest paint" (delayed LCP; on cnet.com 3 336 PseudoClass invalidations sat there vs 2 after LCP), "After the largest paint", "Before first paint β building the page" β deriving between = afterFirstPaint β afterLargestContentfulPaint, which is only valid when LCP β₯ FCP: LCP lands before FCP in real traces, so the totals are compared first and such runs keep the plain first-paint split. Empty between-window collapses to the two-group "After first paint" form (a reason can appear in several windows; mediawiki.org showed 197 of 1 907 "Added to layout" landing AFTER paint β exactly what the category guess buried). Old data falls back to the CATEGORY split ("Added to layout", "Node was inserted into tree" β quieted via `.invalidation-build`), which is deliberately approximate and says so in the whisper copy. Reason CELLS translate Chrome's raw jargon strings to plain language via `INVALIDATION_REASON_LABELS` (raw string kept on the label's `title` when it differs), and the four reasons a developer can act on (`Affected by :has()`, `Inline CSS style declaration was mutated`, `Animation`, `Fonts changed`) carry a one-line fix from `INVALIDATION_REASON_HINTS` under the label (`.invalidation-reason-hint`, muted, both themes). Unverified reasons (`Related style rule`, `Attribute`, `List value change`) and any future Chrome reason fall through to the raw string, never a guessed label. The cell is a `mixin invalidationReasonCell(label, hint, raw)` that takes the ALREADY-RESOLVED label/hint as arguments β this partial is `include`d into the summary/iteration `block content`, so the mixin body cannot see the `- const` maps; the callers resolve `reasonLabel(...)`/`reasonHint(...)` and pass the results in. The blocking card also carries up to three web-font context rows (latest-finishing first pick, hollow `--font` bars, `font/*` mime OR woff2/ttf/otf extension since some servers ship fonts as octet-stream, "finished after first paint" flag in the meta) β and the zero-blocking empty note and the context-row table are NOT mutually exclusive: fonts/document/LCP rows still draw the paint clock when nothing blocked.
- **The playback timeline's busy band is long tasks, clipped like the visual-progress lane.** A thin red lane under the Rendering tab's playback axis draws `pageinfo.longTask` for the median/current run (same run-picking as the why-section) β the CPU half of "why did nothing change here" while scrubbing. The Long Task API keeps reporting past the recording end, so bars starting past `renderingEnd` are dropped and a straddling one clamps β the same lesson the visual-progress long-task lane learned. Adding the lane moved the axis ticks from 60px to 66px and the timeline height from 76px to 82px in BOTH theme files β the lane sits at 58px between axis and ticks; anything new in that band needs the same three-way adjustment.
- **The lightbox frame diff reads pixels, so it dies on file://.** The Diff toggle in the Rendering lightbox draws the previous and current filmstrip frames to a canvas and compares per-pixel (channel-delta sum > 30 β the same threshold that separates real change from compression noise when analysing filmstrips by hand; the changed-share in the caption makes whole-page re-rasterization noise recognizable as a ~1 % text ghost). The best path is a diff PRECOMPUTED by browsertime (`--videoParams.filmstripDiff`, 28.2+): `diff_<ms>.png` next to each filmstrip frame plus a `FrameDiffs` count array in `visualMetrics` β exact, counted, works everywhere including file://, keyed by the frame FILE's ms (not the filmstrip slot time β `--filmstrip.showAll` repeats files across slots) and surfaced as `data-diff*` attributes on `.rendering-frame`. Mind `lib/plugins/browsertime/filmstrip.js`: it builds the frame list from EVERY file in `data/filmstrip/<run>/`, so non-`ms_*` files must be filtered or diff images show up as frames. Without precomputed diffs the canvas computes the same view at click time, and `getImageData` throws SecurityError when the report is opened from file:// (the canvas is tainted even for same-directory images, and fetch is equally blocked there β no API route around it) β the catch switches to a pure-CSS fallback that needs no pixel access: the current frame over the previous with `mix-blend-mode: difference`, amplified and inverted via `filter: brightness(4) invert(1) grayscale(1)` so changes read as dark marks on white. Approximate by design (no threshold, no changed-pixel count β the caption says so and points at http(s)); once the fallback triggers it sticks for the session (`diffBlendMode`) so the canvas path doesn't re-throw per frame. Don't "fix" the catch away. The first frame has no previous, so the button disables at index 0, and stale async renders are guarded by a token since arrow keys re-render the diff while images are still loading.
- **VisualProgress is event-based, not continuous.** Browsertime emits a `VisualProgress` sample only when the similarity-to-final-frame percent changes. Filmstrip frames are snapshots at specific moments. The visual-progress curve must STEP (hold previous percent, step up at next sample), not linearly interpolate β interpolation falsely suggests rendering during a blank period. The scrubber must show the most recent frame whose timestamp is `<=` cursor, not the nearest in either direction. And cursor-to-time math uses the curve's coordinate system (which lives inside a ~38px horizontal inset), not the container width β otherwise the playhead drifts out of sync with the curve. [#4734](https://github.com/sitespeedio/sitespeed.io/pull/4734). The long-task lane shares the curve's axis but the Long Task API keeps reporting until browsertime stops collecting β i.e. past `vpMax` β and nothing clips the lane, so bars past the axis end must be dropped (they still show on the CPU tab) and an edge-straddling bar clamped, or they paint outside the card.
- **The Toplist page derives its short-cache list in the template.** The assets plugin only ships largest-by-type / slowest lists (`assetsBySize`, `slowestAssets`, `thirdPartyAssetsBySize`, `thirdPartySlowestAssets` β item shape in `lib/plugins/assets/assetsBySize.js` / `assetsBySpeed.js`, including `page`/`runPage` for the per-row context links). The "Short or no cache time" card in `toplist.pug` is computed from those pools at render time (dedupe by URL, `cacheTime` under a week, HTML documents exempt because revalidating the document is the correct trade-off, sorted by transfer size) β don't add a new collector for it. Cell tones (`.cell-tone--warning/--error` in `src/sass/*/components/toplist.scss`) mark clearly-problematic values; the thresholds and their WHY live as comments next to the tone helpers in the template. Note pug scoping: mixins can't see `- const` vars declared in `block content`, so the tone helpers are defined inside the `rows` mixin and per-card options travel as mixin arguments.
- **Composition donuts are a shared include, not a per-tab copy.** `templates/url/includes/_compositionDonuts.pug` holds `donutChart(items, preserveOrder)` and `donutCell(caption, items, total, preserveOrder)` β the CrUX tab's conic-gradient ring generalised so three tabs reuse ONE recipe: the PageXray content-type breakdown (`perContentType.pug`: three donuts β transfer / content / requests β sliced by content type, above the table), the third-party first-vs-third breakdown (`firstParty.pug`: three donuts comparing `pagexray.firstParty` vs `.thirdParty`), and the third-party Categories donut (`thirdparty/index.pug`: one donut, requests by category, replacing the old `.tp-bars` list β its `.tp-bar*` styles were deleted from `thirdparty.scss`). Each `items` entry is `{label, value, sub?}`; the optional `sub` renders muted between the label and the share in the legend (categories use it for "67 req Β· 4 tools"; first-vs-third for the per-slice size/count, so each row shows both the actual value AND the share β the content-type donut leaves it off because the table right below carries exact per-type values). Data is already on `pagexray` / `thirdparty.category` β no new collector. The first-vs-third donut card AND the third-party content-type table are gated on `pagexray.thirdParty.requests > 0`: third-party sizes are `undefined` (NOT 0) on a page with no third party, so `firstParty.size + thirdParty.size` is `NaN` and `h.size.format(NaN)` returns `'N/A'` (the requests total rendered a literal `NaN`) β the tab already shows a "No third-party requests detected" banner, so those cards just render N/A soup when empty. Donut totals also coerce `|| 0` because a Safari HAR can carry `requests` but an undefined `transferSize`. Response codes deliberately stay bars, NOT a donut: a healthy page is ~95% one code and a lone 4xx/5xx would vanish into a sliver the status-coloured bars surface. Watch the chip separator in `thirdparty.scss`: it is `content: '\00b7'` (CSS escape for the `Β·` middle dot); a past edit collapsed the `\00` into a literal NUL byte (`content: '<NUL>b7'`), which the browser drew as a tofu box + "b7" between tool chips and turned the whole `.scss` into a git-"binary" file β keep it the backslash escape (or a literal `Β·`), never a raw control byte. Styles + the `--comp-cat-1..7` palette live in `components/composition.scss` (both themes, brighter hues in dark, imported in `main-{light,dark}.scss`) β a SEPARATE `--comp-*` palette from CrUX's `--crux-cat-*` on purpose so neither tab's donut restyles the other. `donutChart` sorts largest-first and colours by index; pass `preserveOrder: true` (first-vs-third does) to keep source order so each ROLE keeps one fixed colour across all three donuts even when the smaller slice flips between metrics. The total rides on the CAPTION line (`.composition-cap-label` + `.composition-cap-total`), not inside the ring β the punched-out hole is too small to read a value in. `perContentType.pug` also dropped four dead arrays (`numberOfRequests`/`transferSizeOfRequests`/β¦) that were leftover from the pre-2024 chartist pie and consumed by nothing. Being under `templates/`, this include IS pug-linted (unlike `crux/` pug), so `if` conditions need `!==`/`===` and buffered output can't string-concat β compute in `- const` lines, keep literal `%` outside `#{}`.
- **Two Rendering cards are ranked lists, not bars, via a shared `.ranked-list`.** "Scripts causing invalidations" (`styleInvalidations.sources`, lead = count) and "Forced reflows before the page painted" (`topReflows`, lead = duration ms) both dropped their `.cpu-bars` fills: the rows arrive pre-sorted, so a bar scaled to the top row just pinned the leader full-width and restated the order β and the reflow bar scaled by `reflow.duration` actively over-weighted the milliseconds the card's own copy ("the number of reflows matters more than the milliseconds") tells you to discount. The shared component is `.ranked-list` / `.ranked-item` / `.ranked-lead` (fixed 4.5rem width so counts and "45 ms" durations align) / `.ranked-body` / `.ranked-name` / `.ranked-sub` in `components/rendering.scss` (both themes). The `.cpu-*` bar classes stay β the CPU tab still uses them in ~seven places; only these two rendering-tab cards moved off them. Both card titles/anchors are unchanged, so the "Start with the script under Scripts causing invalidations" cross-references and the `#reflows-before-paint` deep link still land.
- **The Coach Page info card is sectioned with per-metric explainers.** `templates/url/coach/pageInfo.pug` groups `advice.info` into three `.pi-section`s (Document / DOM structure / Storage and connection), each metric a `+piMetric(label, hint, opts)` tile with a plain-language explainer line under the value. Only two counts carry a tone + flag: `domElements` (warning > 800, error > 1400) and `domDepth.max` (warning > 24, error > 32) β Chrome's Lighthouse DOM-size cutoffs. Everything else is descriptive; don't invent verdicts for viewport / storage / script count (the judgement lives in the Coach advice cards above). Width + Height are folded into one "Layout viewport" row. `opts` travels as an object because the mixin can't see the template's `- const` tone helpers; pug-lint forbids `+piMetric(...)= expr` and string concatenation in buffered output, so value block content is indented and the viewport uses `#{}` interpolation, not `+`. Styles are `.pi-*` in `src/sass/{light,dark}/components/coach.scss` (identical block, theme-agnostic tokens) β rebuild `*.min.css`. This template IS under `templates/`, so pug-lint covers it (unlike `crux/`/`axe/` pug).
- **Extending pug templates can't hold top-level code.** A template that `extends ./layout.pug` may only have named blocks and mixins at the top level β a top-level `- const β¦` throws `PUG:UNEXPECTED_NODES_IN_EXTENDING_ROOT` at compile time. Put constants inside `block content`, and pass them to mixins as arguments: mixins compile to standalone functions and do NOT see variables declared in the block that calls them.
- **Assets page overview vs table.** The `aggregateassets.summary` handler in `lib/plugins/html/index.js` computes the overview-strip totals (transfer/size/type counts/biggest asset) over ALL unique assets before the list is capped to `--maxAssets` (default 100, an undeclared-in-yargs top-level option) β mind the `splice` there, it mutates the list, so totals must be computed first. The cap SELECTION uses the same weight rule the table sorts by (`transferSize || size`, descending) β selecting by request count let one-off heavy assets fall off the page entirely. The per-URL asset shape (`lib/plugins/assets/aggregator.js`) carries `size` = contentSize and `transferSize` = wire bytes; `transferSize` can be missing (Safari HARs, old data), hence the fallback. The capped list feeds only the HTML Assets page; `analysisstorer` and `graphite` consume the raw uncapped message.
- **The Rendering tab tells the full render story.** It absorbed the old Video, Filmstrip and Screenshots tabs plus the "why was rendering delayed" diagnostics: `templates/url/rendering/index.pug` renders (in order) the playback card + filmstrip (driven by `templates/url/includes/renderingView.js`, styled by `components/rendering.scss`), the per-run screenshot gallery, and the why-section β the recalculate-style card (browsertime `renderBlocking.recalculateStyle`, same run-picking as the Metrics tab: `pageSummary.renderBlocking[medianRun.runIndex - 1]` on summary, `run.renderBlocking` on iteration) and the render-blocking requests card (`rendering/blocking.pug`, reads a `pagexray` local built the same way the PageXray tab builds it: `run || pageSummary`). `#video`, `#filmstrip`, `#screenshots` and `#requests-render-blocking` are a URL contract: `tabScripts.js` maps all four hashes to `#rendering` and anchors with those ids stay inside the section β keep them alive if you rename anything. The PLAYBACK view degrades by CONFIGURATION, not file sniffing: `hasVideo` comes from `--video` (plus `--enableVideoRun` on the summary page only, where the extra video is always `data/video/1.mp4`), `hasFilmstrip` from the createFilmstrip/trace-screenshots gate; filmstrip-without-video swaps the player for a frame preview and drops speed buttons + download link; with NEITHER, no player/preview/transport/timeline renders at all (the milestone timeline is interaction-coupled to a playback medium, so it never renders standalone). The gallery and why-cards are gated on DATA, and the `hasRendering` const in `url/summary/index.pug` + `url/iteration/index.pug` mirrors all of it β the tab exists for video OR filmstrip OR screenshots (iteration only; screenshots are per-run, the summary page never had them) OR a slow TTFB (`timings.ttfb` β₯ 800 ms β the Web Vitals good threshold, same field as the Metrics tab's TTFB tile; renders as the leading why-note with share-of-FCP and a HAR-gated Waterfall cross-link β note the HAR lives at `browsertime.har` on summary but `browsertime.run.har` on iteration pages) OR pagexray `renderBlocking` OR recalculate-style data OR the trace-derived CPU render signals (forced reflows with `startTime` before the run's latest paint milestone β max of FCP and LCP renderTime, since LCP can land before FCP β or any non-composited animations β `cpu.forcedReflows` / `cpu.nonCompositedAnimations`, per-run under `pageSummary.cpu[medianRun.runIndex - 1]` on summary, `run.cpu` on iteration) OR frame-delivery data (`cpu.frames.presented` defined β the Frames KPI section moved here from the CPU tab: dropped frames are the smoothness half of "what did the user see"; anchor `#frames`, with `#frames` and the old `#cpu-frames` aliased to `#rendering` in tabScripts.js). Those two can exist while recalculate-style is empty (recalc needs an FCP/LCP trace event; the other trace analyses don't), hence their own gate clause; the reflow card windows to before the latest paint milestone with per-milestone FCP/LCP tiles and a share-of-milestone note, and only renders when the pre-paint total is β₯ 10 ms (materiality floor, mirrored in both page gates β keep in sync; no paint milestone β no card β whole-load numbers live on the CPU tab), counts sub-millisecond reflows in the totals but drops them from the offender list, and both why-items deep-link to the CPU tab's `#cpu-forced-reflows` / `#cpu-animations` anchors via an inline `selectTab` call (tabScripts.js maps those hashes to `#cpu` for reloads/shared links). The `--cpu` hint for the recalculate metrics lives here now (still Chromium-only β Gecko profiles don't carry `UpdateLayoutTree.elementCount`); the Metrics tab keeps its `renderBlocking` const declared solely because the LCP card (`metrics/lcp.pug`) keeps its one-line recalculate-before-LCP entry. Milestone flags use the median RUN's own values (`pageSummary.browserScripts[medianRun.runIndex - 1]`), not cross-run medians β they must line up with the recording β and are sorted purely by time (LCP before First Visual Change happens in real runs; don't reorder or clamp). Per-frame completeness steps from the most recent VisualProgress sample `<=` the frame timestamp (see the VisualProgress note below). Keyboard handling only fires while the section is visible (`view.offsetParent !== null`) and never when typing in a form control.
- **The Compare tab is one row per metric, and its anchors are a contract.** `lib/plugins/compare/pug/index.pug` renders a one-line setup strip, a collapsed `<details>` Run setup (screenshots/video β the foundation strips the native disclosure triangle, so the SCSS draws its own), the content-breakdown table, a verdict card with jump chips, then group cards with one row per metric: name + p-value, a distribution strip (baseline/current runs as DOM dots on a shared value axis β run order carries no meaning for independent samples, so there is NO run-index axis), median β median with Ξ%, and a chip. Row ids keep the old `#chart-<group>_<metric>` scheme β old deep links must keep landing. **Every metric keeps its own row** β a first iteration merged byte-identical twins (before-FCP/before-LCP pairs whenever FCP and LCP are the same paint) into one stacked row and the maintainer rejected it: they are different measurements that happen to agree, and the stacked names broke the name column mid-word. `metricGroupRows` still detects twins via `JSON.stringify` of both value arrays but only to tag the later row `same values as X` (`.cmp-same`); the verdict counts every row separately. **Direction words follow the unit** via `dirWord`: ms metrics say slower/faster, counts and unitless metrics (render-blocking elements, tasks, CLS) say higher/lower β 'slower' on an element count read as a bug. The name column is 220px because `beforeLargestContentfulPaint` wrapped its last letter at 200px. Direction tones: a significant change is tinted by direction (faster = ok-green, slower = error-red), derived from `--compare.alternative` and, for two-sided tests, from the per-metric Cliff's delta SIGN (positive = current ranks higher = slower; all compared metrics are lower-is-better). **A significant row whose medians DISPLAY the same value (`fmtVal(medB) === fmtVal(medC)`) is the warning tier, not error**: amber tint, `detectable, tiny` chip with the nonzero-run counts when they differ, means in the Ξ column, its own `.cmp-verdict-sub` sentence and amber verdict chips β the Wikimedia CLS case (0.0005 β 0.0005 with p = 0.029) must not read as a regression. `fmtVal` keeps two significant digits below 0.1 so tiny CLS values stop collapsing to `0 β 0`. Every tested row also carries the **Hodges-Lehmann shift estimate + 95% CI** (`shift: {estimate, lower, upper, confidence}` on the pageSummary metric β additive field, computed in `helper.js#hodgesLehmann` as the median of all pairwise currentβbaseline differences with order-statistic CI bounds from the Mann-Whitney normal approximation; pure JS, pinned by `test/compareTests.js`). It renders as a second `small` line in the Ξ column (`.cmp-metric-delta small` is `display: block` so the lines stack); the display gates on `!informational && shift.lower !== undefined`, and the CI is the honest "how big": interval away from zero = real, straddling zero = noise. Every row also gets a two-sample **Kolmogorov-Smirnov p** (`statisticalTestKS`, additive field from `statistical.py` β computed whenever the primary test ran, skipped on identical/different-length short-circuits). It SURFACES only when the rank test did NOT flag the row AND the likely-opposite call didn't either (K-S fires on plain location shifts too β double-flagging helps nobody; the gating condition lives in two places, the row's `shapeChange` const and the verdict-count loop, keep them in sync): amber row, `spread changed` chip, middle-80% span line, own verdict sub-line. The `isSignificant` graphite export stays rank-test-only. The statistics explain themselves for non-statisticians: `P_EXPLAINER`/`EFFECT_EXPLAINER` consts feed hover titles on the p line, every `.cmp-effect` Cliff's-delta line and the K-S line (visible text there is plain "same median, runs land differently", the test name lives in the title), and the verdict card ends with a collapsed `details.cmp-help` "How to read the numbers" primer (p, shift, effect, spread changed, rule of thumb) β keep those explanations in sync when thresholds or wording change. Ξ and shift are two different answers to "how much" (difference of medians vs Hodges-Lehmann over all run pairs) and they disagree slightly on noisy data β so quiet rows DROP the bare Ξ line whenever a shift line renders (`hasShiftLine`; old baselines without the shift field keep Ξ) and only significant rows keep Ξ for its percentage. The setup strip's test internals moved into a collapsed `details.cmp-about` "about the statistics" fold naming every algorithm (rank test with method/continuity woven into sentences, Hodges-Lehmann, Cliff's delta with the effect-tier thresholds spelled out β 0.147/0.3/0.5, keep in sync with `cliffDeltaHelper` β and K-S) β full-width flex child of `.cmp-setupline`. The K-S row wording is DATA-DRIVEN, never assume the median held (K-S fires on shifts the one-sided test can't call, and "same median" next to 14.7 β 12.0 read as a bug): medians display-equal β `spread changed`, moved + spread grew β `less steady`, else `runs changed`; the spread line reads "run-to-run spread A β B" (p10βp90 width, title explains). **One-sided tests can never flag an improvement**, so a non-significant row with |Cliff's delta| β₯ 0.474 in the untested direction gets a `likely faster`/`likely slower` chip plus a one-line explanation instead of `no change`. When both medians are 0 but runs differ (flaky CPU long tasks), the Ξ column falls back to means β don't "fix" that to show 0 β 0. Units are derived from group + metric name (helper.js ships a fixed group set; unknown groups like user extras get NO unit rather than a guessed one), and rows guard on `v.baseline && v.current && Array.isArray(...)` because extras metrics can arrive half-shaped. Series colours are validated pairs (light `#b25e12`/`#2a78d6`, dark `#c9701f`/`#4a90e0` β the dark pair is brightened for the dark surface; keep the two theme files in sync). The graphite score export (`metrics.*.statisticalTestU` paths in `lib/plugins/compare/index.js`) is untouched by the HTML layout β don't confuse the two. `statistical.py` short-circuits ONLY identical datasets β constant-but-DIFFERENT samples must reach Mann-Whitney (scipy handles zero variance fine; the old `has_variability` guard skipped the test whenever either sample was constant, which suppressed exactly the cleanest replay-lab shifts, all-303 β all-310). The `No variability` string can still appear in data produced by older versions, so `isInformational` in the pug keeps recognizing it. `docs/documentation/sitespeed.io/configuration/config.md` is GENERATED from `--help-all` by the release workflow β fix CLI choices/describe text in `lib/cli/options/*.js`, never in that file.
- **The Compare distribution strip is built to answer "real or noise?", and it is HTML dots, not SVG.** Each rail is absolutely-positioned DOM elements inside a relative rail β inline SVG with `preserveAspectRatio='none'` stretched circles into ellipses at wide widths, so dots/bands/ticks are `%`-positioned spans instead (the `xPos`/`xPosN` helpers share the same `pad`-inset scale so bands align with dots). Behind the dots each series draws its **middle-80% band** (`quantile(values, 0.1..0.9)`, `.cmp-strip-band`) and a **grey overlap stripe** (`.cmp-strip-overlap`) spanning both rails across the value window the two bands share (`ovLo=max(p10s)`, `ovHi=min(p90s)`, only when `ovHi>ovLo`) β a wide grey band means the median shift sits inside run-to-run noise, a clean gap means it's real. The overlap is rendered FIRST in the DOM (inside `.cmp-strip-rails`, which must be `position:relative`) so the hairline/bands/dots paint over it; z-index order is overlap/band `0` β dot `2` β median tick and counted pile `3` (the pile renders after the tick so a count stays legible when the median sits on it). **Runs piling on a handful of distinct values render as counted pills** (`railMarks`: pills only when the series has β€ 5 distinct values AND a value carries > 2 runs, everything else stays jittered dots) β without them twenty WebPageReplay runs at 303 ms read as one dot against one outlier. **Series labels live in a 52px gutter left of the rails** (`.cmp-strip-row` grid; the old overlay `.cmp-strip-tag` is gone because dots/pills painted over it) and `.cmp-strip-axis` carries `margin-left: 60px` = gutter + gap to stay aligned with the rails. **Effect size (Cliff's delta via `cliffDeltaHelper`, `negligible` tier < 0.147) renders under EVERY chip as its own `.cmp-effect` line**, significant or not β the pill itself carries only the short verdict (`slower`, `no change`, `detectable, tiny`) so a number never wraps alone inside the pill (the old single-pill layout orphaned `(0.286)` on its own line in the 150px chip column). The median-delta **percentage renders only when significant**; non-significant rows show just the absolute `Ξ` and dim the whole median line via `.cmp-metric-delta--quiet` β a `+3 %` on a noise row otherwise reads as a real regression the overlap band denies. This pug lives OUTSIDE `lib/plugins/html/templates/`, so `npm run pug-lint` does NOT cover it (it can use `!==`-free `if`s etc.) β verify changes with a `pug.compileFile` instead. There is no client JS: the strip is pure server-rendered HTML/CSS, so it survives `file://` and needs no resize handler. To preview without a browser-driven run, reconstruct a real `compare.pageSummary` by feeding two saved `{browsertime,pagexray}` JSONs (from `--compare.saveBaseline --compare.baselinePath <dir>`, which needs the dir to pre-exist β `saveBaseline` uses bare `writeFile`) through the real `helper.js` `getMetrics` + `statistical.py`, then render the pug with `h = lib/support/helpers/index.js`.
- **Folder path aliases accept Unicode.** Non-ASCII URL components used to collapse to `-` in result paths (issue #3880). The path-alias regex now permits Unicode letters and digits. Don't tighten it back. [#4759](https://github.com/sitespeedio/sitespeed.io/pull/4759).
- **CSS rebuilds are required.** Editing SCSS under `lib/plugins/html/src/sass/` does nothing on its own β run `npm run build:css` and commit the compiled `*.min.css` under `lib/plugins/html/assets/css/`. The build step writes both light and dark themes.
- **Per-URL data is keyed by message type, stored nested.** `dataCollector.addDataForUrl` uses `set()` from `lib/support/objectPath.js`, which splits the message-type path on `.`. So `browsertime.pageSummary` and `crux.pageSummary` land as `pageInfo.data.browsertime.pageSummary` / `pageInfo.data.crux.pageSummary` β nested objects, not literal dotted keys. Read them with `h.get(pageInfo.data, 'crux.pageSummaryβ¦')` (also dot-splitting) or direct nested access; a literal `pageInfo.data['crux.pageSummary']` lookup returns nothing.
- **CrUx field data exposes two shapes.** `crux/repackage.js` produces flat per-metric keys (`loadingExperience.ALL.LARGEST_CONTENTFUL_PAINT_MS.p75`, etc.) **and** keeps the raw API response under `loadingExperience.ALL.data.record.metrics.<snake_case>.percentiles.p75`. Each flat key only exists if that metric was in the CrUx response, so guard before reading. The summary template reads the raw `.data` path; the Web Vitals lab-vs-field line uses the flat keys.
- **The CrUx tab leads with a verdict and draws share breakdowns as one composition bar each.** `lib/plugins/crux/pug/index.pug` lives OUTSIDE `lib/plugins/html/templates`, so `npm run pug-lint` does NOT cover it β that is why it can use `!= null` in `if` conditions where the linted templates must use `!== undefined`. It opens with an actionable Core Web Vitals verdict (`.crux-verdict`, ok/warning/error tones) computed by `cwvAssess` from the page's own `loadingExperience` ALL form factor, falling back to `originLoadingExperience` (the banner then says "This origin"). Only LCP / INP / CLS gate the pass β a metric passes when its p75 is in the good range, which is equivalent to β₯75% of real users being good, so the p75 threshold alone is the assessment; FCP / TTFB / RTT stay diagnostic. Tone is error if any of the three is poor, warning if some are only needs-improvement (worded "close to passing"), ok when all present pass. The three share breakdowns (form factors, navigation types, LCP resource type) are each ONE `compositionPie` mixin β a pure-CSS conic-gradient donut (`.crux-donut`, centre punched out with `::after` filled `$color--surface-card`) plus a legend, sorted largest-first and normalised to the slice total so the ring always closes β replacing the old per-bucket blue `.crux-bar*` rows. NO charting dependency (the pre-2024 CrUx pie grid used `chartist` + inline JS, deliberately removed) and it works on `file://`. The three donuts lay out in a `.crux-breakdowns` grid (`repeat(auto-fit, minmax(300px, 1fr))`) so they sit side by side on a wide card and wrap otherwise; the LCP image-breakdown stays full width BELOW the grid (pulled out of the LCP-resource `if`). That breakdown is a "where the time went" phase bar (`.crux-lcp`), not the old `dl`: the four image-LCP sub-parts (TTFB, resource load delay, resource load duration, element render delay) render left-to-right in timeline order as flex segments coloured from the same `--crux-cat-1..4` palette, with a legend (ms + share) and a pointer at the biggest phase carrying a phase-specific fix. Crucial honesty trap: each sub-part is its OWN p75, so they are independent and the parts routinely EXCEED the LCP total (aftonbladet's load delay p75 1.887 s vs LCP p75 1.183 s) β the bar therefore flexes on the phase SUM, never on `LARGEST_CONTENTFUL_PAINT_MS`, and a caption states the parts may not add up to the LCP. Only render when at least one phase field exists (the `LCP_IMAGE_*_MS` keys are gated like the flat keys β guard before reading). The slice colours are the `--crux-cat-1..7` CUSTOM PROPERTIES declared on `.crux-card`, NOT plain hex β the donut's inline `conic-gradient(var(--crux-cat-N) β¦)` and the legend dots (`.crux-cat-N { background: var(--crux-cat-N) }`) must read the same theme-aware source, which an inline style cannot get from an SCSS `$var`; the dark theme overrides the same custom-property names with brighter hues. The palette is a maximally-distinct qualitative set (blue/amber/green/magenta/violet/cyan/slate) ordered so consecutive slices differ β an earlier all-cool palette was too close to tell slices apart; the tinted verdict banner carries the pass/fail story, so donut hues overlapping the status colours does not read as a verdict. A bucket at 100% renders as one solid ring. Pug gotcha hit here: a `-` JS block across `-` lines must be one complete statement PER LINE β the `defs` array had to collapse onto a single line or babel throws "Error parsing body of the with expression". The `cwvAssess`/`compositionPie`/`joinAnd` helpers and the categorical palette live only in this tab; the summary page's own CrUx card is the separate `_summaryCrux.pug` (still a stacked bar β the donuts are the tab-only treatment).
- **The Timings Summary table's markup leans on HTML parser error recovery.** In `url/summary/summaryBox.pug` the `getRow` mixin emits the min/median tds nested *inside* the metric-name `td` (the `each`/`if` blocks are indented under it) β the browser's tree builder hoists td-inside-td into siblings, so the rendered table is fine but the source order isn't the DOM order. Consequence: style its columns via classes / `td[data-title=β¦]` attribute selectors (scoped under `.timings-table`), never `nth-child`. When `iterations` is even the median is computed (average of the two middle runs) so it links to no run β that branch renders a plain cell and needs its own `data-title`/class. The "Runs agree?" column (the `agreeCell` mixin) grades run-to-run AGREEMENT, never speed β the Β±N% is half the minβmax span as a share of the median, in four bands: identical / stable Β±N% (span β€ 10% of median, Β±5%) / varies Β±N% (span β€ 20%, Β±5β10%, neutral tone β ordinary cloud-runner noise, not a verdict) / runs disagree Β±N% β so it needs no per-metric limits. The disagree pill drops its Β±N% when the median is 0 (flaky TBT 0/0/214 ms: a percentage of nothing means nothing; the max column carries the value). It only renders with `--iterations` > 1 (a single-run table would read "identical" everywhere). User Timing rows have an extra trap: a mark can fire several times in one run (repeating ads) or only in some runs, so its stats cover a different sample count than the iterations and a computed median matches no run β `getUserTimings` must then still emit a plain unlinked cell, or every later column shifts left (that bug shipped once as a "missing Max" report). Also note: a min/median/max cell only renders when some run's value matches the stat (that's how the run links resolve), so inconsistent hand-mocked data shifts columns left β real summarizeStats output always matches. The table is now **split into one `.listing-card` per metric family** (Google Web Vitals / Visual Metrics / CPU / More metrics / User Timing) instead of a single table broken up by full-width `td.extraheader` divider rows: the `timingsGroup(title)` mixin wraps the shared header + `table.timings-table` chrome and takes the rows as `block` content, so each family's `+getRow`/`+getUserTimings` calls render into that card's `tbody`. `getRow`/`getUserTimings`/`agreeCell` are unchanged. A `p.summary-section-label Timings summary` (reusing the hero's section-label class) carries the `#browsertime-timing-statistics` anchor the quicklink targets. Each card only renders when its family has data (More metrics guards on `hasMoreMetrics` so an empty firstPaint/loadEventEnd family doesn't leave a titled-but-empty card).
- **The Domains page ranks by a derived time footprint, and its inputs are in the summary-page locals.** `topDomains` (built in `lib/plugins/html/index.js` from the `domains.summary` message, capped at 200) carries per-domain `summarizeStats` objects (`median/mean/min/p90/max`, plus `sum` only on `transferSize`) β any phase can be missing entirely when every HAR timing was `-1`, so cells must guard and still emit `data-value="0"` (sortable.min.js reads `data-value` before text; a bare `β` in the first row would flip the whole column to alpha sorting). Rows are pre-sorted by `requestCount Γ totalTime.median` β a deliberate proxy for "which domain cost the most wall-clock", documented in the template. First/third-party marking prefers `options.firstParty` and falls back to hostnames of the tested pages via `headers.pages.pages` (the summary-page locals include `headers = this.summary`, so top-level pages CAN see the tested URLs β no extra plumbing needed). Two pug gotchas learned here: mixins compile as separate functions and cannot see `- const` helpers declared in `block content` (pass them as mixin arguments, like the existing `transferMax`), and browser globals/builtins (`URL`, `Set`, `RegExp`) DO resolve inside templates β pug's `with` wrapper falls back to `typeof X !== "undefined" ? X : undefined`, which is why `Math` already worked.
- **The Google Web Vitals hero is one shared partial.** `lib/plugins/html/templates/url/metrics/index.pug` builds the `wvTiles` scoreboard and is `include`d by both `url/summary/index.pug` and `url/iteration/index.pug`. Edit it once; it renders on both the median-summary and per-run pages. The Web Vitals thresholds (`gwvLcpClass`, β¦ and `wvStatusLabel`) are defined inline near the top of that file β reuse them rather than re-deriving cutoffs. BUT: those helpers are assigned without `const` inside `if browsertime`, and pug inlines every include of a page into one function scope. Declaring `const gwvLcpClass` in another include of the same page (e.g. `summaryBox.pug`, which renders BEFORE the metrics include executes) would make the later plain assignment throw "Assignment to constant variable" β that's why `summaryBox.pug` carries its own `sb*`-prefixed copies with a keep-in-sync comment, same as `pages.pug` (a separate compile unit) keeps `gwv*` copies.
- **Report table conventions.** Numeric cells/headers use the foundation `td.number`/`th.number` (right-aligned; give sortable ones `data-value` so sortable.min.js sorts numerically β `+numberCell` in `_tableMixins.pug` does). `.responsive` is a WRAPPER DIV around a table, never a class on the table itself: the foundation gives it `overflow-x: auto` on desktop and Chris Coyier row-stacking below 800px. Data-heavy listing tables (pages, timings, assets, toplist, detailed, domains) share the 0.95rem cell treatment β new ones should add the `.data-table` class (foundations/tables.scss) instead of re-declaring the size. Cell tone classes are spelled `cell-tone--warning` / `cell-tone--error` (toplist/domains/assets all agree now). `.listing-card-title` is `h3` everywhere (its size comes from the class, not the element); tab lead headings ("Waterfall", "Coverage", "PageXray", "CPU") are `h2`.
- **Per-URL page chrome is shared through `templates/url/includes/`.** In-page jump links are the `+quicklinks(links)` mixin (`_quicklinks.pug`, renders a `.listing-quicklinks` pill row from `[{href, label}]` β callers build the array keeping their data conditions); the run navigation is the `.listing-quicklinks.run-pills` row whose `#pageNavigation` id MUST stay because `tabScripts.js` re-appends the active tab hash to run links on click (the current page is a `span.current` chip, styled in `components/listings.scss`). The **iteration** page uses `_renderPhase.pug` (the "Where the time went" bar) and `_metricsKpis.pug` (the page summary leads with `_summaryHero.pug` now instead β see the next note; the Assets overview strip still renders the KPI classes). `_metricsKpis.pug` is the overview card β ONE `.overview-card` with quiet uppercase group subheads over stat grids where the value leads and the label whispers; it opens with a Google Web Vitals group (TTFB/FCP/LCP/CLS/INP/TBT, carrying the "median of N runs" meta) followed by Loading, Page weight & requests (Total content size renders only NEXT TO transfer size β alone it reads as a second, contradicting "size"), CPU and Visual progress; no verdict colors or badges by design β that story lives in the Timings Summary "Runs agree?" column and the Web Vitals verdicts elsewhere in the report): pug includes are inlined into the caller's scope, so each page pulls its own values into the same-named locals (summary reads `pageSummary.statistics.*` medians, iteration reads `run.*`) and sets `overviewIsMedian` before including β the include must stay data-source-agnostic (`overviewIsMedian` drives the "median of N runs" meta in the first group head). The CrUx block still renders as an `.iter-group` card below the overview card, so the `.iter-group*` styles in `iterationSummary.scss` stay even though the overview no longer uses them. The screenshot column is a `figure.shot-card` captioned with WHICH run it shows ("Median run (run 4) Β· after page complete" / "Run 2 Β· β¦"); the img keeps `.iter-screenshot` for the portrait height cap while the card removes the img's own border. Two per-page quirks live in the callers on purpose: iteration rounds `fullyLoaded` between the two includes (the bar needs the exact float, the list shows whole ms), and the URL line under the h1 is `p.url` (styled like the old h5 in `components/misc.scss`) so the heading outline doesn't jump from h1 to h5. Redefining a pug mixin is legal β every partial includes `_quicklinks.pug` itself, and the last definition wins harmlessly.
- **The page summary leads with an action-first hero (`templates/url/summary/_summaryHero.pug`), not the KPI overview.** It replaces `_renderPhase` + `_metricsKpis` on the SUMMARY page only β iteration pages keep those shared includes. In order it renders: a verdict lede (a plain-language sentence that judges several dimensions β paint/LCP, interactivity/TBT, layout/CLS, accessibility/axe, Coach best-practice + privacy scores (Coach performance is skipped β it echoes paint/interactivity), and server/TTFB + page weight when notably bad β colour-coding each phrase green as a strength or amber/red as a weakness and composing them as "This page is fast to paint and visually stable, but janky to interact with"; the `verdictList` mixin renders the a/b/and-c list, paint + interactivity are always judged, the rest join when present/notable), the Coach report card (`d.coach.pageSummary.advice.{score,performance.score,bestpractice.score,privacy.score}` β score leads in neutral text, tile tinted by `h.scoreLabel` to match the Coach page exactly: β₯90 green, β₯80 amber, else red; no letter grade), the hero, a visual-metrics row, a Google Web Vitals chip strip, and "What to act on" β the top 3 real Coach performance offenders (`advice.performance.adviceList.<rule>` carrying `{title, score, advice, description}`, `score < 90`, worst-first; the `advice.advice` text is the actionable finding). The hero is a filmstrip when `hasFilmstrip` (same gate as `rendering/index.pug`; the `filmstrip` local β median-run frames `{time(s), file}` built in `htmlBuilder.js` for the summary page β drawn as a STATIC six-frame teaser tagging the first frame AT OR AFTER FCP/LCP (iβ₯1, never the blank start frame β "nearest" tagged the 0 ms white start as Largest paint when an early LCP sat between two sparse samples) plus the last as First paint / Largest paint / Complete; `lcp` uses `max(renderTime, loadTime)` like the Metrics tab so a cross-origin LCP image with `renderTime` 0 doesn't read as 0, images at `data/filmstrip/<medianRun.runIndex>/<file>`; the interactive player stays on the Rendering tab), otherwise a chronological Web Vitals scorecard (order TTFBβFCPβLCPβTBTβCLSβINP β the order they happen during load) plus the median screenshot and a `--video` nudge. The phase timeline (NetworkβRender-blockingβLargest paintβTail with FCP/LCP/end milestone flags) is metric-derived, so it draws in both hero modes; the end flag is "Visually complete" (`lastVisualChange`) with a filmstrip, "Fully loaded" without. Threshold helpers carry a `psv*` prefix ON PURPOSE: `metrics/index.pug` BARE-assigns the `gwv*` names later on the same compiled page (a `const gwv*` here would throw "assignment to constant"), and `summaryBox.pug` already owns the `const sb*` copies β same cutoffs, keep all three in sync. Visual metrics (`firstVisualChange`/`speedIndex`/`visualComplete85`/`lastVisualChange`) are `undefined` without video, so the visual-metrics row, the filmstrip and the "Visually complete" flag only appear then. `pug-lint` enforces `!==`/`===` in `if` conditions (but NOT inside `-` raw-JS lines, which is why the `sb*`/`gwv*`/`psv*` helpers can keep `== null`) β since `get()` returns `undefined` for a missing metric, the lede guards on `!== undefined`. The now-unused metric pulls left in `summary/index.pug` (`mpFID`, `longTasks`, `firstPaint`, `memory`, `requests`, `visualComplete99`, β¦) fed `_metricsKpis` and stay put because the CrUx block and other consumers still read the surrounding locals. Styles live in `components/summaryHero.scss` in BOTH themes (dark swaps `$color--blue-dark`β`$color--blue` for the highlight accents; the four phase-band fills are literals so the band reads the same in both) β rebuild with `npm run build:css` and commit the compiled `*.min.css`. **Tone tinting:** tiles/cards/chips (`.summary-sv`, `.summary-rc`, `.summary-chip`, `.summary-act`) reuse the report's `ok`/`warning`/`error` tint tokens (`$color--*-bg`/`-border`, the SAME treatment as the Web Vitals hero `.web-vital`) so a passing metric reads green β the Coach card leads with the score in neutral text (no letter grade), tinted by `h.scoreLabel` (β₯90 green / β₯80 amber / else red) so it and the verdict's Coach best-practice/privacy phrases match the Coach page's own tile colours. A tone class that is NOT element-qualified must be SCOPED: the lede verdict phrases use `.summary-lede-hl.is-ok` (light tint bg like the Coach overall tile), because a bare `class="ok"` picks up the report's global `.ok { background:#15803d }` utility and renders a solid dark-green background β the block elements are safe only because `.summary-sv.ok` etc. out-specify it. **Accessibility (axe-core)** is folded in when `d.axe.pageSummary` exists: median violation counts per severity (`violations.{critical,serious,moderate,minor}.median`) as tinted tiles (a zero level is a green tile), and critical+serious feed a "Fix accessibility violations" card into What to act on. **CrUX** field data renders right below the hero via `_summaryCrux.pug` (`summary/index.pug` includes it) β tinted p75 tiles (`.crux-kpi`) + good/needs-improvement/poor distribution bars (`.crux-distro*`), reusing the CrUX plugin's own `crux.scss` classes (ALL form factor, same source the old plain `dl` read; don't duplicate it into the hero). The filmstrip hero's "How it loaded" heading is a `.summary-stage-link` to the Rendering tab (the report's `selectTab(document.querySelector('#tabs #rendering'), false)` cross-tab pattern) since the full playback lives there. `.summary-act-text` sets `overflow-wrap: anywhere` because Coach advice can list long unbreakable ad-SDK URLs that otherwise spill past the card.
- **The run navigation is number-only chips inside `#pageNavigation`.** Three templates render it (`url/summary/index.pug`, `url/iteration/index.pug`, `url/summary/metrics/index.pug`) as a `nav.run-nav` with a "Runs" label, number chips (`.run-nav-chip`, current run/page gets `--current`), a divider and pill links (Side by side; the side-by-side page also gets Summary β the run pages don't, their "Back to summary" button already covers it). The `#pageNavigation` id is a contract: `tabScripts.js` re-appends the active tab hash to EVERY link inside it on click. Chips carry `aria-label='Run N'` since the visible text is just the number. Styles live in `components/listings.scss` in both themes.
- **`.loader` looks dead but isn't.** `components/loader.scss` styles the spinner `fetchHAR.pug` shows while the waterfall fetches the HAR (`_waterfallRender.pug` removes the class when loaded). Don't delete it on an "unused CSS" sweep. Also: `summaryBox.pug` contains a non-breaking space in `if min ||Β min === 0` (valid JS whitespace, invisible in editors) β exact-match tooling that types a normal space won't find the line.
- **Every summary page can see the run's errors and the budget verdicts through its locals.** `htmlBuilder` sets `this.summary.errors = { errors, menu }` when any `error` message arrived, and every summary page gets `headers = this.summary` β so `headers.errors.errors` is the per-URL error map (`{ url: { tool: [messages] }, generic: {β¦} }`) on Pages / Detailed / anywhere, no extra plumbing (the scorecard's failed-pages line and the "N errors during testing" chips read it). Budget items (built in `lib/plugins/budget/verify.js#getItem`) are structured β `{ metric, value, friendlyValue, limit, friendlyLimit, limitType: 'min'|'max', status }` grouped per URL/alias in `budget.failing` / `budget.working` β so render them as columns, not a prose sentence. On the PageXray tab `pageInfo.data.browsertime.console` is the raw console log (`[{ level, message }]`, Chrome levels β `SEVERE` is what counts as an error, matching `consoleLogAggregator`). The `.errors-chip` component (sass `components/errors.scss`) is the shared error-toned pill for "bad news" cross-links.
- **The Google Web Vitals hero is one shared partial.** `lib/plugins/html/templates/url/metrics/index.pug` builds the `wvTiles` scoreboard and is `include`d by both `url/summary/index.pug` and `url/iteration/index.pug`. Edit it once; it renders on both the median-summary and per-run pages. The Web Vitals thresholds (`gwvLcpClass`, β¦ and `wvStatusLabel`) are defined inline near the top of that file β reuse them rather than re-deriving cutoffs.
- **The start page is a scorecard ("every page is a dot") with a boxes fallback.** `templates/index.pug` reads the site-wide summary stats through the locals that `htmlBuilder` merges in from `dataCollector.getSummary('index')` β they surface as top-level `browsertime`/`pagexray`/`coach`/`axe`/`sustainable` locals, each `{ summary: β¦ }`, and any of them can be missing (pug resolves unknown identifiers to `locals.*`, so plain truthiness guards work). CrUx data is deliberately NOT on the start page β it is per-URL/origin field data and belongs on the page reports. The per-page dots and the Page-by-page list come from the `scorecardPages` local computed in `htmlBuilder.js`. In scorecard mode summary boxes are deliberately NOT rendered (no letter grades either β they collide with WebPageTest/GTmetrix); only when there is no browsertime summary or no gradeable subject does the template fall back to the classic hero+boxes grid. The subject value column shows median + worst (the stat objects carry `min/median/p90/max`) because the extreme page dot IS that value; the Coach performance score is a higher-is-better subject (`hib` flag) β grading inverts, its track zones are mirrored and worst is the MIN. Note the FCP summary stat lives at `summary.paintTiming['first-contentful-paint']` (top level, not under `timings.`) and needs bracket access β `objectPath.get` splits on dots only.
- **Summary boxes are no longer user-configurable.** `--html.summaryBoxes` / `--html.summaryBoxesThresholds` were removed in the major after the scorecard redesign made them fallback-only. The box set and thresholds now come exclusively from `defaultConfig.js` and `setup/summaryBoxesDefaultLimits.js`; `setup/summaryBoxes.js` imports both directly instead of reading `options.html`. Note WHY it can't read `options.html`: the html plugin's `open()` does a SHALLOW `Object.assign({}, defaultConfig, options)` and yargs always materialises `options.html`, so `defaultConfig.html.*` never survives the merge β defaults that must always apply have to be imported at the point of use. The text plugin's terminal summary shares `summaryBoxesSetup` and therefore the same defaults.
- **Division after `]` breaks pug attribute expressions.** Inside a tag attribute, `style='width:' + m[1] / mmax + '%'` makes pug's character-parser treat `/` as a regex start ("no closing bracket found"). Compute the value in a `- const β¦` line first and interpolate the variable. Also remember mixins compile to separate functions: they see locals but NOT consts/functions defined in `block content`, so pass everything a mixin needs as arguments.
- **The Detailed summary's cards are driven by group tags, not name matching.** `setup/detailed.js` tags every metric row with a `group` (`coach`, `timings`, `visual`, `cpu`, `pageWeight`, `requests`, `responseCodes`, `axe`, `sustainable`, `custom`) via `addRows`; `detailed.pug` renders one `.listing-card` per group and routes anything untagged to "Other metrics" so new rows never silently disappear. TBT and Max Potential FID are deliberately tagged `timings` even though browsertime derives them from the CPU long-task data β they're Web-Vitals-class metrics. The single-value collapse (one "Value" column instead of min/median/mean/p90/max) is detected from the data β every visible metric has `min === max` β NOT from `--iterations`, because a multi-page run with one iteration each still produces a real spread across pages. Response-code anomaly rules: 4xx/5xx flag on `max > 0` (a flaky 404 with median 0 still matters); "redirect heavy" is 3xx β₯ 10% of the median total requests (fallback β₯ 20/page when there's no request count, e.g. macOS Safari without a HAR), and 304 is excluded β it's a cache revalidation, not a redirect hop. The "What stands out" axe chip triggers on critical violations only (`max > 0`, same any-run semantics) β serious violations ride along in the same chip when criticals exist but never trigger alone, or the strip would flag most of the web and stop meaning anything; the chip deep-links to the card anchors (`#detailed-card-<group>`).
- **The Axe tab leads with a verdict and sorts violations worst-first.** `lib/plugins/axe/pug/index.pug` lives OUTSIDE `lib/plugins/html/templates`, so `npm run pug-lint` does not cover it. The per-run branch (`pageInfo.data.axe.run`) MUST sort `axe.violations` by impact rank (critical > serious > moderate > minor) before grouping β the raw array is in rule order, so serious can render below minor (the old "worst first" comment was aspirational; enwiki/aftonbladet showed serious β minor β moderate). It opens with an `.axe-verdict` banner tinted by the WORST impact present (`impactClass`: critical/serious β error, moderate β warning, minor β info) β headline is rule count + affected element count (summed over each violation's `nodes.length`), note points at where to start β then an `.axe-medians` severity scoreboard (rule count leads, elements whisper; tiles tint only when count > 0), then the violations listing card. Each `details.axe-violation` carries a `tone-{error,warning,info}` class for the leading accent stripe (`border-left: 3px`, not the bare `error`/`warning` class, to avoid the global status utilities) and an `.axe-violation-count` element pill in the summary row. The aggregate branch (page summary) now has a real zero-state (`.axe-clear` when every median is 0) and its own worst-impact verdict; it reads `axe.violations[imp].median` / `axe.violationIssues[imp].median` (impact-keyed objects, NOT an array β the run branch is the array). Verdict/scoreboard styles live in `components/axe.scss` (both themes); `$color--info*` tokens back the minor tier.
- **`objectPath.get` splits on `.` only β bracket paths silently miss.** `get(o, "timings.paintTiming['first-contentful-paint'].median")` returns the default because the bracket expression becomes one literal segment. Since the key itself has no dot, the working spelling is `timings.paintTiming.first-contentful-paint.median`. Beware: several `path` values in `lib/support/friendlynames.js` (FCP) use the bracket form, so resolving friendlyNames paths through `get()` yields undefined for those metrics β render a `β` for undefined rather than a fake `0`.
- **The Pages table (`pages.pug`) is worst-first with a fixed verdict column.** Rows sort by Web Vitals severity (worst of the measured TTFB/FCP/LCP/TBT/CLS/INP medians from `browsertime.pageSummary.statistics`), then LCP, then FCP β a stable sort so no-data pages keep test order and sink to the bottom. The metric columns remain driven by `--html.pageSummaryMetrics` (a documented contract β don't hard-code columns); descriptors resolve once from `friendlyNames` and unknown metric names are skipped instead of rendering a broken column (the pre-redesign template crashed the whole page on an unknown name). Missing values render `β` with `data-value=-1` so sortable.min.js still sorts numerically. The default `pageSummaryMetrics` set (`lib/plugins/html/defaultConfig.js`) is timing-first β `googleWebVitals.largestContentfulPaint` / `.cumulativeLayoutShift` / `.totalBlockingTime`, `timings.SpeedIndex`, `transferSize.total`, `requests.total`, `score.performance` β and the old auto-appended First Visual Change / Speed Index / Last Visual Change columns (the `hasPageSummaryMetricInput` mechanism) are gone: Speed Index is a regular default column now, dash when video wasn't collected. Changing the defaults array must keep resolving through the `friendlyNames[tool][group][key]` lookup β `lib/cli/validate.js` validates user input through the same lookup. The client-side URL filter lives in `templates/includes/pagesFilter.js` (included via `script` + `include`, the same pattern as `tabScripts.js`) and only ships when the run has more than 5 pages. Styles are in `src/sass/{light,dark}/components/pages.scss`, sized to match `.timings-table` (0.95rem cells, tighter padding, ellipsis-truncated URL column with the full URL in the link title). Beware: some docs code blocks (`docs/documentation/sitespeed.io/configure-html/index.md`) are indented with non-breaking spaces β exact-match edits fail unless you preserve them.
## Project metadata
- **License:** MIT (see `LICENSE`). `package.json`'s `license` field matches. When adding dependencies, prefer permissive licenses (MIT, Apache-2, BSD, ISC, MPL). Avoid GPL-family deps unless there is a strong reason and explicit discussion β `tools/check-licenses.js` audits this on every install. LLM-generated code carries license risk inherited from training data; review carefully before committing, especially for non-trivial blocks that resemble identifiable upstream code.
- **CONTRIBUTING:** `.github/CONTRIBUTING.md` β read this before changing the contribution flow. Documents the AI-assistance disclosure convention.
- **PR template:** `.github/PULL_REQUEST_TEMPLATE.md`. The pre-flight checklist (issue opened, tests, squashed commits, docs updated, `npm test` + `npm run lint` clean) is the bar for merging.
- **Issue tracker:** <https://github.com/sitespeedio/sitespeed.io/issues>. Templates in `.github/ISSUE_TEMPLATE/` β `BUG_REPORT.yml`, `FEATURE_IMPROVEMENT.yml`, `QUESTION.yml`. Use the right template.
- **Bug reports** require: a description, OS + version info, the URL being analysed (or an email to the maintainer if it's sensitive), screenshots where relevant, and the `sitespeed.io.log` in a gist. See `.github/CONTRIBUTING.md#add-a-defect` for the exact instructions.
- **Slack channel** for contributors: invite link in `HELP.md`.
- **Sponsors:** <https://www.sitespeed.io/sponsor/>. The funding configuration lives in `.github/FUNDING.yml`.
- **Code of conduct:** `CODE_OF_CONDUCT.md`. Applies to issues, PRs, Slack, and any other project space.
- **Support:** `SUPPORT.md` covers user-support channels.
- **Roadmap:** `ROADMAP.md` for planned direction. Check before proposing large features.
- **Help wanted:** `HELP.md` lists the ways non-code contributors (designers, doc writers, sponsors) can help.
- **Security policy:** `SECURITY.md` documents the reporting channel β [GitHub's private vulnerability reporting](https://github.com/sitespeedio/sitespeed.io/security/advisories/new), with [email protected] as an email fallback. It requires reporters to spell out a concrete exploitation chain (who the attacker is, what they control, what they gain, a reproducer) rather than just citing a finding. The threat model is "a measured page or HAR file trying to pivot into the host running sitespeed.io" β *not* a network attacker hitting it as if it were a server. The file also takes an explicit stance on transitive-dependency CVEs: a high-severity CVE in a dep that isn't reachable through sitespeed.io's actual code paths or attacker-controlled inputs is not a sitespeed.io vulnerability, and gets picked up via the normal dependency-bump flow rather than an emergency fix. It also states that the burden of proof sits with the reporter β confirming a CVE is *not* reachable is proving a negative the maintainers won't do on demand, so a bare scanner list gets closed with a pointer to `SECURITY.md`. `.github/ISSUE_TEMPLATE/config.yml` reinforces this with two `contact_links` that steer security reports to private vulnerability reporting and scanner-CVE reports to the relevant `SECURITY.md` section before a public issue is opened. If you change the reporting channel, the threat model, or the in-scope/out-of-scope wording, update `SECURITY.md`, the `config.yml` links, and this note together.
## Code commentary
- Inline comments are sparse. When you find one, it almost always documents a Docker / platform / plugin / browser quirk. Treat them as load-bearing β don't delete on "cleanup" passes.
- Don't explain what readable code already shows. Only explain why.
Discover similar high-velocity repositories, agent skills, and OpenAPI specifications across the ecosystem.
Topic hubs, agent specifications, and quick tools