diff --git a/.env b/.env
index 721156e5..ab1cd5f2 100644
--- a/.env
+++ b/.env
@@ -1,7 +1,5 @@
-BROWSER=firefox
-
NODE_ENV=development
-REACT_APP_GA_TRACKING_ID='UA-00000000-0'
-REACT_APP_SITE_TITLE='Firefox Public Data Report'
-REACT_APP_VALUE_DECIMAL_PLACES=3
+VITE_GA_TRACKING_ID='UA-00000000-0'
+VITE_SITE_TITLE='Firefox Public Data Report'
+VITE_VALUE_DECIMAL_PLACES=3
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index f945ebb5..00000000
--- a/.eslintignore
+++ /dev/null
@@ -1,2 +0,0 @@
-build
-package-lock.json
diff --git a/.eslintrc.extra.js b/.eslintrc.extra.js
deleted file mode 100644
index 5fddd280..00000000
--- a/.eslintrc.extra.js
+++ /dev/null
@@ -1,74 +0,0 @@
-module.exports = {
- env: {
- browser: true,
- es6: true,
- node: true,
- 'jest/globals': true,
- },
- extends: [
- 'eslint:recommended',
- 'plugin:react/recommended',
- 'plugin:jsx-a11y/recommended',
- 'plugin:jest/recommended',
- ],
- parser: './node_modules/babel-eslint',
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- ecmaFeatures: {
- jsx: true,
- },
- },
- plugins: [
- 'json',
- 'jsx-a11y',
- 'react',
- 'jest',
- ],
- root: true,
- overrides: [
- {
- files: ['src/tests/jest/*.js'],
-
- // In src/setupTests.js, these globals are defined in such a way
- // that they are available to all Jest tests
- globals: {
- React: true,
- shallow: true,
- },
- },
- ],
- settings: {
- react: {
- version: "16.4.2",
- },
- },
- rules: {
- // Errors
- 'eqeqeq': 'error',
- 'no-global-assign': 'error',
- 'no-redeclare': ['error', { builtinGlobals: true }],
- 'no-shadow': ['error', { builtinGlobals: true }],
- 'no-var': 'error',
- 'prefer-const': 'error',
- 'no-console': 'error',
-
- // Stylistic warnings
- 'semi': ['warn', 'always'],
- 'comma-dangle': ['warn', {
- "arrays": "always-multiline",
- "objects": "always-multiline",
- "imports": "always-multiline",
- "exports": "always-multiline",
- "functions": "never"
- }],
- 'prefer-arrow-callback': 'warn',
-
- // Plugins
- 'jsx-a11y/label-has-for': ['error', {required: {every: ['id']}}],
- 'jsx-a11y/no-onchange': 'off',
- 'react/display-name': 'off',
- 'react/prop-types': 'off',
- 'react/no-unescaped-entities': ['error', {forbid: ['>', '}']}],
- },
-};
diff --git a/.gitignore b/.gitignore
index 014182fe..f031f74d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,8 +3,8 @@
/node_modules
# Tests
-/*driver.log
-/tests_output
+/test-results
+/playwright-report
# Generated files
/src/**/css/*
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..a45fd52c
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..7412010f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,294 @@
+# Contributing to ensemble
+
+This is the canonical reference for building, testing, and changing this repository. For what the
+system is and how its pieces fit together, see `docs/architecture/` instead — this file is about
+how to work in the code, not what the code is.
+
+## Build, test, and development commands
+
+ npm install # confirmed clean on Node 24; no special flag needed
+
+ npm start # npm-run-all --parallel watch:css watch:app -> Vite dev
+ # server on :3000
+ npm run build:css # one-shot Stylus compile; confirmed working, no flags needed
+
+ npm run lint # lint:js (flat-config ESLint, covers .js and .jsx) + lint:styl
+ npm run test:jest # vitest run; confirmed passing, no flags
+ npm test # lint -> test:jest -> test:playwright
+
+ npm run build:app # vite build; confirmed working, no flags needed
+ npm run build:version.json # writes build/version.json; must run after build:app, needs git on PATH
+ npm run size # source-map-explorer on build/assets/index-*.js (needs a prior build)
+ npm run test:playwright # playwright test; starts the dev server itself if one
+ # isn't already running on :3000
+
+Run one Vitest file directly: `npx vitest run src/tests/jest/Dashboard.test.jsx` (confirmed
+working). Run one Playwright spec directly: `npx playwright test contact.spec.js`.
+
+**Environment:** `.env` is checked into git and holds only public build-time config —
+`NODE_ENV=development`, `VITE_GA_TRACKING_ID='UA-00000000-0'` (a placeholder),
+`VITE_SITE_TITLE='Firefox Public Data Report'`, `VITE_VALUE_DECIMAL_PLACES=3`. These are consumed by
+`src/components/decorators/withTracker.jsx` and `src/lib/utils.js` via `import.meta.env.VITE_*` —
+Vite's own convention, not Node's `process.env` (which is not populated in the browser bundle at
+all, except for the one special-cased `process.env.NODE_ENV` expression Vite replaces for
+library-compatibility reasons). Override any of them inline: `VITE_SITE_TITLE='…' npm start`. This
+repository is a fully client-side bundle — anything in a `VITE_*` variable ships to the browser in
+plain text, so a real credential never belongs in one, and this `.env` file deliberately holds none.
+
+## Coding style and naming conventions
+
+- `.jsx` for any file containing JSX — including `src/index.jsx` and `*.test.jsx`. Plain `.js` only
+ for non-JSX modules (`lib/utils.js`, `setupTests.js`, `registerServiceWorker.js`, and the
+ Playwright helper/config files under `tests/playwright/` and at the repository root).
+- PascalCase filenames matching the default-exported component. Decorators are camelCase with a
+ `with` prefix; `lib/lazyLoad.jsx` and `lib/LazyBoundary.jsx` are the exceptions (a small loader
+ helper and an error-boundary component, not components themselves in the first case).
+- One component per file, always `export default`. There are no named component exports anywhere in
+ this repository; the only named exports at all are the four helpers in `lib/utils.js`.
+- The container/view split is the organizing principle — see `docs/architecture/frontend.md`.
+ `containers/X.jsx` holds state and fetching and renders `views/X.jsx` with formatted props
+ (`ChartContainer` → `Chart`, `DataTableContainer` → `DataTable`). Keep new state out of `views/`.
+- Function components are the default (17 of 19 views), written as anonymous default-exported
+ arrows — `export default props => (...)` — with no `displayName` (hence `react/display-name:
+ off`). Use a class only where state, refs, or lifecycle are genuinely needed: all 6 containers
+ plus `SummaryMetric.jsx` (d3 + `React.createRef`), `Spinner.jsx`, `MetricOverview.jsx`,
+ `Footer.jsx`, and `LazyBoundary.jsx` (an error boundary, which must be a class component).
+- Leading underscore for private class-property handlers (`_onRegionChange`, `_drawChart`,
+ `_setChartWidth`) — applied inconsistently (`setChartSize` in `ChartContainer.jsx`); prefer the
+ underscore in new code.
+- Locals named `maybeX` hold a JSX fragment or `null` (`maybeDescription`, `maybeSummaryMetrics`,
+ `maybeRegion`, `maybeGraphURL`). Keep the idiom.
+- Styles attach via a global CSS side-effect import of the generated file at the top of the
+ component: `import './css/Dashboard.css';`. Some components import several (`Chart.jsx` pulls
+ `metrics-graphics/dist/metricsgraphics.css`, `./css/Chart.css`, `./css/Metric.css`). `Metric.css`
+ and `LabelledSelector.css` are shared partials with no component of their own.
+- No TypeScript, no PropTypes, no CSS-in-JS or CSS Modules, no Prettier, no i18n. No MPL license
+ headers on source files (unlike other MozMEAO repositories) — do not add them.
+- `.editorconfig`: LF, final newline, trim trailing whitespace, 4-space indent for
+ `py,yml,html,css,styl,js,json,md`. `.jsx` is missing from that list but 4-space is the de facto
+ convention. No max line length is configured anywhere.
+- Every dependency is exact-pinned — no `^` or `~` in `package.json`. Keep it that way.
+
+**ESLint — one flat-config file, `eslint.config.js` at the repository root, covering both `.js` and
+`.jsx`.** `npm run lint:js` runs `eslint .` with no other flags; there is no separate config for
+`.jsx` and no invisible bundled config supplying one either (unlike the old create-react-app-era
+setup, which linted `.jsx` invisibly through `react-scripts`). Errors: `eqeqeq`, `no-var`,
+`prefer-const`, `no-console`, `no-global-assign`, `no-redeclare` and `no-shadow` — the latter two
+with `builtinGlobals: true`, so do not name a variable `name`, `status`, `history`, `event`, etc.
+Warnings: `semi: always`, `prefer-arrow-callback`, `comma-dangle` (trailing commas required for
+multiline arrays, objects, imports, and exports; never for function args or params). Off:
+`react/prop-types`, `react/display-name`, `jsx-a11y/no-onchange`. `react/no-unescaped-entities`
+forbids only `>` and `}`. `settings.react.version` is `'detect'` (reads the installed React version
+automatically, rather than a hardcoded string). `src/tests/jest/**/*.test.jsx` additionally gets
+`@vitest/eslint-plugin`'s recommended rules and Vitest's ambient globals (`it`, `expect`,
+`beforeAll`, etc. — the same globals `src/setupTests.js` attaches for the tests themselves). Quote
+style is not enforced (there is no `quotes` rule) — single quotes by convention. The `build`
+directory is excluded via the config's own `ignores` array (flat config's replacement for
+`.eslintignore`, which no longer exists).
+
+## Styling: Stylus, outside webpack
+
+`src/components/views/styl/*.styl` compiles to `src/components/views/css/*.css` via the `stylus`
+CLI (`npm run build:css` / `watch:css`). Vite never sees the Stylus — this pipeline predates the
+build tool and remains fully independent of it. One `.styl` file per component (PascalCase,
+mirroring the component), plus `Application.styl` (global reset, Fira Sans `@font-face`, body type,
+roughly 217 lines) and shared partials `Metric.styl` and `LabelledSelector.styl`.
+
+Shared variables live in `src/components/views/styl/includes/lib.styl`, and it is nearly empty:
+`$base-tablet`, `$base-desktop`, `$link-color-normal = #0070ff`, and a `// TODO: padding/margin
+spacing.` comment. Import it per-file with `@import 'includes/lib'` — it is not globally injected.
+There is no mixins file; most colors are hardcoded hex inline (stylint's `colors` check is off).
+
+This styling is not BEM. ID selectors carry the page landmarks — `#application`, `#main-header`,
+`#main-navigation`, `#dashboard`, `#dashboard-sections`, `#summary-metrics`, `#region`,
+`#introduction` — with flat lowercase-dash classes for repeated pieces (`.metric-overview`,
+`.dashboard-section`, `.data-table-wrapper`, `.labelled-selector`, `.next-button`, `.striped`,
+`.highlighted`, `.bar-label`). Descendant nesting with `&` handles states; responsive rules live in
+`@media $base-tablet` / `@media $base-desktop` blocks at the bottom of each file. **Those element
+IDs are also the end-to-end test selectors — renaming one breaks those tests silently.**
+
+stylint (`.stylintrc`) runs with `maxErrors: 0` and `maxWarnings: 0`, so any finding fails the lint.
+It enforces the CSS-like Stylus dialect, not the terse indented syntax: 4-space indent, single
+quotes, `brackets: always`, `colons: always`, `semicolons: always`. Also `noImportant: true`,
+`leadingZero: false` (`.5`, not `0.5`), `zeroUnits: never` (`0`, not `0px`), `universal: never` (no
+`*`), `prefixVarsWithDollar: always`, `namingConvention: lowercase-dash` with
+`namingConventionStrict: true`, `zIndexNormalize: 10`. Escape hatches are `// @stylint off` / `on` /
+`ignore` comments — see the `@font-face` block in `Application.styl`.
+
+## Writing code for the next reader
+
+Code is read far more often than it is written, and in this repository the next reader is often
+arriving after a long gap. Optimize for them.
+
+- Use full, descriptive names. No single letters, no invented abbreviations. `activeRegion`, not
+ `r`.
+- Comments explain what and why, briefly, and only where the logic is non-obvious. Well-named code
+ needs none. Delete comments that restate the code.
+- Comments must stand on their own. Never reference a spec, a plan document, a requirement label, or
+ a ticket ID as the explanation — the comment must make sense to someone who has only the file in
+ front of them. A cross-reference alongside a self-contained explanation is fine; that is how the
+ `metrics-graphics` workaround notes in `views/Chart.jsx` read.
+- Never describe code that no longer exists. After a refactor, delete the comments it falsified.
+ Prefer deleting an obsolete comment, branch, or test over leaving it beside its replacement.
+
+## Testing guidelines
+
+**Vitest + Enzyme**, configured in the `test` block of `vite.config.mjs` (the same file that
+configures the Vite build) — `environment: 'jsdom'`, `globals: true`, `setupFiles:
+['./src/setupTests.js']`, scoped to `include: ['src/tests/jest/**/*.test.jsx']` specifically so
+Vitest's own default file-discovery glob doesn't also try to run Playwright's `.spec.js` files.
+Only two files, both in `src/tests/jest/`, named `PascalCase.test.jsx` (the directory is still
+named `jest` even though Vitest is what actually runs these — a naming leftover from before this
+repository's toolchain migration, not worth a rename on its own). `src/setupTests.js` puts `React`
+and `shallow` on the global object, so test files import neither: they `import Dashboard from
+'../../components/views/Dashboard';` and call bare `shallow()`. The existing style is
+top-level `it(...)` with long descriptive sentences, a `beforeAll` that builds a `requiredProps`
+object, and assertions via `.find(selector).exists()` and `.html()).toContain(...)`. Shallow
+rendering only; no snapshots; `react-refetch` is never mocked; there are no container tests.
+
+**Playwright** specs live in `tests/playwright/specs/`, configured by `playwright.config.js` at the
+repository root. Specs are `camelCase.spec.js` or `kebab-case.spec.js` (matching the component or
+page under test), CommonJS, using `@playwright/test`'s own `test`/`expect` — for example
+`test('Page loads', async ({ page }) => { await expect(page.locator('#contact')).toBeVisible(); })`.
+Shared helpers (`linkWorks`, `linksWork`, `flagForUpdate`, `metricTitleIsCorrect`) live in
+`tests/playwright/utils.js`. `flagForUpdate` deliberately fails when an element count changes, to
+force a human to look. The `dashboards/*.spec.js` specs assert exact metric titles and section
+ordering against live production data, so upstream data changes break them by design — this is not
+a bug in the tests, and updating their expectations to match reality is normal, expected
+maintenance. Two browser projects: `chromium` (everything except `jsDisabled.spec.js`) and
+`chromium-no-js` (`jsDisabled.spec.js` only, run with `javaScriptEnabled: false` — this is
+Chromium-only because `nightwatch.conf.js`, the file this suite replaced, only ever tested Chrome
+too; it is not a gap introduced by this migration). Playwright starts the dev server itself
+(`webServer: { command: 'npm start', ... }`) if one isn't already running on `:3000`, reusing an
+existing one when there is one. Point the suite at a different environment with
+`PLAYWRIGHT_BASE_URL`, for example `PLAYWRIGHT_BASE_URL=https://data.firefox.com npx playwright
+test` — there is no separate `test:playwright:stage`/`test:playwright:prod` script for this, unlike
+the old Nightwatch scripts, since the environment variable already covers it in one line.
+
+Tests describe the code as it is now. Assert what the code does; never add a test whose purpose is
+to prove that removed behavior is absent — a negative assertion about history passes forever while
+documenting nothing. Each test builds only the data it needs; there is no shared mega-fixture. Keep
+assertions at the point where the thing is rendered rather than behind a `_getElement(wrapper)`-
+style indirection layer, so a failure points straight at the markup it cares about.
+
+## Footguns
+
+| Trap | What happens |
+|---|---|
+| Switching branches without reinstalling | `node_modules` is not tied to git state. A branch switch can leave it holding packages from a different branch's `package.json` (a missing binary, a wrong dependency version resolved) that look like genuine bugs. Run `npm install` after every branch switch before trusting any command's output. |
+| Gitignored `src/components/views/css/` | A fresh clone cannot resolve the CSS imports until `npm run build:css` runs. Never hand-edit `css/` — edit `styl/`. |
+| Renaming a landmark ID | Breaks Playwright selectors silently. |
+| `build:version.json` | Needs `build/` to already exist and `git` on PATH; must follow `build:app`. |
+| `no-shadow` / `no-redeclare` with `builtinGlobals` | Naming a local `name`, `status`, `history`, `event` is a hard error. |
+| A first-time route visit against the Vite dev server | Can occasionally 504 or fail a dynamic import ("Outdated Optimize Dep") while Vite's dependency pre-bundler catches up with a newly-discovered chunk. A reload resolves it. This does not happen against a production build (`vite build`/`vite preview`), which has no dependency pre-bundling step. |
+| `metrics-graphics` charts and `path.mg-line1` | Each chart renders many invisible `.mg-voronoi` hover paths that also carry the `mg-line1` class (confirmed: 292 matches for one chart). A Playwright locator for this selector needs `.first()`, or it throws a strict-mode "multiple elements matched" error. |
+
+Known rot, recorded for recognition only, not as a roadmap: `react-app-polyfill` and
+`babel-polyfill`, plus a `browserslist` of `>0.2%, not dead, not ie <= 10, not op_mini all`, were
+IE11-era targeting — both removed in the Vite migration, since IE11 support was already effectively
+dead. `dateformat@3.0.3` is several majors behind current, but the newer majors change its module
+export shape in a way that needs call-site changes, not just a version bump — deliberately left
+alone (see `docs/architecture/frontend.md`'s dependency-ceiling section). `distinct-colors@3.0.0`
+has no newer major.
+
+## Git workflow
+
+Work on a dedicated branch named `--kebab-case-summary` (for example,
+`409--hardware-resize-performance`). If the issue ID is not obvious from the request, ask for it,
+along with a short summary for the branch name, then read
+`https://github.com/mozilla/ensemble/issues/ISSUE_ID` before writing any code — many of these
+issues are years old, and the discussion carries context the title doesn't.
+
+Work that will not finish in one session, or that touches several files, a migration, or a tooling
+replacement, gets a written design document — an ExecPlan — committed to `docs/execplans/` alongside
+the change it describes. See that directory's own `README.md` for the shape; a single-file fix or
+anything a commit title already describes does not need one.
+
+## Commit and pull request guidelines
+
+Keep commit titles short and imperative, and reference the issue when there is one (`Display times
+using UTC timezone`, `Fix hardware resize thrash (#409)`).
+
+This project maintains a hand-managed version number in `package.json` that is not semver — the
+first digit for major changes, the second for medium, the third for small — historically bumped at
+deploy time. Do not bump it casually; ask.
+
+If a changeset adds a GitHub Action or workflow (there are none today), check it with
+[Zizmor](https://zizmor.sh/) before considering the work complete.
+
+### Writing PR descriptions
+
+There is no `.github/PULL_REQUEST_TEMPLATE.md` in this repository, so use these four headings every
+time: **One-line summary**, **Significant changes and points to review**, **Issue / Bugzilla
+link**, **Testing**. Answer one that doesn't apply with `n/a` — one word is a complete answer, and
+inventing content to fill a heading is worse than admitting it is empty.
+
+A description directs the reviewer's attention. It does not restate the diff, and it does not
+explain this codebase back to the team that owns it. Check what actually changed
+(`git diff --stat main...`, plus a scan for config, fixtures, and deletions) before writing, so the
+description matches reality.
+
+Take length out of describing the change.
+
+**One-line summary.** One or two sentences: what the change does, and why, where the title does not
+already make that obvious. Compress rather than qualify. "Debounce the hardware chart resize
+handler" beats a long sentence explaining the whole mechanism and its motivation — say the point
+once, and let the bullets below carry the rest.
+
+**Significant changes and points to review.** Short bullets, most significant first, one clause
+each. Nest a sub-bullet only where a consequence needs one; never go past two levels. Name a file
+where the file is the point — "Remove the unused `BrowserHacks.styl`" — and prefer the bare filename
+when it identifies the thing on its own.
+
+Rejected, as a first draft might read:
+
+> - **Debounced resize handler** (`ChartContainer.jsx`) — the hardware dashboard was thrashing on
+> every resize. **This is the most critical part to review** — it's the redraw path every chart on
+> the site shares.
+
+Replacement, as it should read:
+
+> - Replace one unthrottled `resize` listener per chart with a single, debounced listener shared
+> across all `ChartContainer` instances.
+> - The hardware dashboard's 11 charts no longer each trigger their own redraw on every resize event
+> (#409).
+
+- Say what changed. Add why only where the change does not already imply it.
+- Do not rate your own change. No "low risk," "mechanical," "straightforward," and never a reflexive
+ "this is the most critical part to review" — most PRs have no such thing, and a rating carries
+ nothing a reviewer can act on.
+- Do flag a change that is genuinely high-risk or wide blast radius, in one line, stating its reach
+ as a fact rather than a rating: a change to the `markdown-it` sanitization allowlist (reaches every
+ metric description on the site), a renamed landmark ID (breaks the end-to-end suite silently), or
+ anything touching `config.json`'s shape rather than just its content.
+- A side-effect worth knowing about but not risky — a duplicate file deleted, a stray import removed
+ — is a plain bullet like everything else.
+- Fold low-risk, mechanical fallout — a regenerated lockfile, a renamed CSS class, a fixture update
+ — into its own short item after the substantive bullets, so it doesn't crowd out what the reviewer
+ actually needs to think about.
+- Flag genuine uncertainty about your own work — distinct from rating risk. Naming a real unknown is
+ often the most useful sentence in the description.
+- Never explain mechanics the team already knows: why `react-refetch` isn't mocked in a test, how
+ the container/view split works, what `flagForUpdate` does.
+- Never describe your own process — not which approach you tried first, not what a review caught,
+ not that the branch is stacked on another. Deliberately deferred scope is worth stating
+ ("Ports the Nightwatch specs as-is; re-baselining the stale content assertions is a separate
+ pass"), as is a change's provenance where it matters ("Matches the Playwright config shape already
+ used in mozmeao/springfield and mozmeao/bedrock").
+
+**Issue / Bugzilla link.** The full tracker URL, or `n/a`. List all of them where a change closes
+more than one, and give context on a cross-reference: "Follow-up to #409, addressing the
+WebKit-specific slowness noted while porting the end-to-end suite."
+
+**Testing.** What a reviewer does by hand to check this. The one section worth expanding.
+
+- Open with prerequisites where there are any: "Run `npm run build:css` first."
+- Include setup commands a reviewer must run to see the change at all. Leave out test and lint
+ commands (`npm test`, `npm run lint`) — those only re-check what CI, once it exists, already
+ checks.
+- Give the URL to load — `http://localhost:3000/dashboard/hardware`, not just "the hardware
+ dashboard" — and the expected result once there.
+- One step per thing to verify, phrased as a check — "Check the dashboard doesn't hang on resize,"
+ not "resize the window → dashboard redraws instantly." `- [ ]` checkboxes where the reviewer is
+ working through a list.
+- Ask plainly for a close look when you want one.
diff --git a/README.md b/README.md
index ef7909da..3d19ed72 100644
--- a/README.md
+++ b/README.md
@@ -6,21 +6,17 @@ Ensemble fetches data from
[ensemble-transposer](https://github.com/mozilla/ensemble-transposer), a JSON
server that adds metadata to the raw data hosted by Mozilla data engineers.
-Ensemble is written in React with the help of the wonderful
-[create-react-app](https://github.com/facebook/create-react-app) tool from
-Facebook. See the [create-react-app documentation](https://facebook.github.io/create-react-app/docs/getting-started)
-for more information. Some highlights and some additional information are
-provided here.
+Ensemble is written in React, built with [Vite](https://vite.dev/).
## Run
### For development
-Run `npm start`
+Run `npm start`. See `CONTRIBUTING.md` for the full command reference.
Any of the environment variables in *.env* can be overridden. For example:
-`REACT_APP_SITE_TITLE='Firefox Public Lore Report' npm start`
+`VITE_SITE_TITLE='Firefox Public Lore Report' npm start`
### In production
@@ -31,13 +27,8 @@ Any of the environment variables in *.env* can be overridden.
## Development
-### Testing
-
-Run `npm test` to run Jest, Nightwatch, and ESLint tests locally.
-
-Nightwatch tests can optionally be run against the staging and production sites.
-Run `npm run test:nightwatch:stage` or `npm run test:nightwatch:prod`
-respectively.
+See `CONTRIBUTING.md` for build, lint, and test commands, coding conventions,
+and the current status of this repository's test suites.
### Analyzing
diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md
new file mode 100644
index 00000000..100e6a1c
--- /dev/null
+++ b/docs/architecture/frontend.md
@@ -0,0 +1,150 @@
+# Frontend
+
+This file describes how this repository's own code is organized: a React single-page application
+built with Vite, that renders data it fetches at runtime and stores none of its own. For the system
+beyond this repository, see `system-overview.md` and `data-pipeline.md` in this directory; for how
+to build, test, and change this code, see `CONTRIBUTING.md` at the repository root.
+
+## `src/config.json` is the spine
+
+Confirmed by reading the file directly: `src/config.json` has two arrays, and almost every
+structural change to this application starts by editing one of them.
+
+`dashboards` currently has three entries (`user-activity`, `usage-behavior`, `hardware`), each
+shaped `{ key, menuTitle, source, supportsRegions }`. `key` drives the route
+`/dashboard/` in `src/components/views/Main.jsx` and the navigation entry in
+`src/components/views/Header.jsx`; `source` is an absolute production URL — confirmed, all three
+currently point at `https://data.firefox.com/datasets/desktop/`, which is exactly the bucket
+path `data-pipeline.md` describes the transposer writing to. There is no environment variable for
+the data source: you cannot point this application at a local transposer instance without editing
+this file directly. **Adding a dashboard is adding one entry to this array.**
+
+`nextButtons` has three entries shaped `{ from, to, text }`, each driving one "Proceed to …"
+call-to-action via `src/components/decorators/withNextButton.jsx`.
+
+## Data flow
+
+This application has no state library, no hooks-based state, and no Redux — state lives in
+`this.state` on class-based container components, plus one `sessionStorage` key,
+`preferredRegion`, set in `DashboardContainer.jsx`. Three containers do all of the data fetching:
+
+| Container | Fetches | Notes |
+|---|---|---|
+| `containers/DashboardContainer.jsx` | `${source}/index.json` | title, description, metaDescription, sections, dates, metrics, summaryMetrics, `categories` (the region list), defaultCategory |
+| `containers/MetricOverviewContainer.jsx` | `${dashboardSource}/${activeRegion}/${slug}/index.json` | title, description, `type: 'line'` or `'table'`, axes, columns, data, annotations |
+| `containers/SummaryMetricContainer.jsx` | the same per-metric endpoint | buckets any population under 5% into an "Other" bucket |
+
+`react-refetch@3.0.1`'s `connect()` higher-order component is the entire data-fetching layer and is
+used in exactly those three files. Each one handles its fetch's pending, rejected, and fulfilled
+states itself and renders `views/Spinner.jsx` or `views/Error.jsx` accordingly. All reshaping of
+fetched data happens client-side in each container's `formatData` method — `ChartContainer` sorts
+its populations by maximum y-value and drops nulls; `DataTableContainer` sorts rows by value,
+descending.
+
+## Rendering metric descriptions: a real sanitization boundary
+
+Metric `description` strings are rendered as inline Markdown and inserted via
+`dangerouslySetInnerHTML` — React's own escape hatch for inserting raw HTML, named the way it is
+specifically to make a reader stop and check what's flowing into it. Confirmed by reading every call
+site (`grep -rn "dangerouslySetInnerHTML" src/components/`): there are exactly four, two in
+`views/Dashboard.jsx` (lines 57 and 65) and two in `views/MetricOverview.jsx` (lines 34 and 40).
+
+The two files configure their `markdown-it` parser independently of each other, not from one shared
+configuration — worth knowing before changing either, since a change to one does not affect the
+other. Confirmed by reading both: `Dashboard.jsx` uses `markdownIt('zero').enable(['emphasis'])`;
+`MetricOverview.jsx` uses `markdownIt('zero').use(markdownItSup).enable(['link', 'entity'])`.
+Together, across the two files, exactly `link`, `entity`, `emphasis`, and `sup` are enabled on top
+of the `zero` preset (which starts with every rule disabled, including raw HTML passthrough).
+`src/tests/jest/MetricOverview.test.jsx` exists specifically to guard this — never widen either
+file's enabled-rule list without extending that test to match.
+
+### Security posture, as of this writing
+
+`npm audit` reports 17 vulnerabilities against a fresh install (3 moderate, 14 high, 0 critical).
+This is down from an earlier, much worse baseline of 251 (21 of them critical), confirmed at the
+time by tracing all 21 critical-severity package names through `npm ls `: every one resolved
+through exactly one of four build-only dependency roots — `react-scripts`, `nightwatch`, `request`,
+or `npm-run-all` — none of which was imported by any file under `src/`, so none of them shipped to
+the browser. All four have since been removed from this repository entirely (`react-scripts` and
+`npm-run-all` replaced, `nightwatch` and `request` no longer used at all), which is why the critical
+count is now zero.
+
+Of the advisories that do affect packages actually bundled into the shipped build, each sits behind
+a code path this application doesn't exercise: `markdown-it`'s flagged `linkify`/`smartquotes` rules
+are the ones disabled by the `zero` preset described above; a flagged `d3-color` ReDoS advisory
+needs an attacker-controlled color string, and this application has none; a flagged `react-router`
+regex advisory needs parameterized routes, and this application's four routes
+(`/`, `/contact`, `/dashboard/`, and a catch-all) are all static.
+
+The one real, structural gap: **there is no Content-Security-Policy**, and the four
+`dangerouslySetInnerHTML` sites above rely entirely on `markdown-it`'s `html: false` default
+holding — true as of this writing, but not enforced by any test or header. Adding a CSP is a genuine
+piece of security work, not something this documentation pass does on its own.
+
+## Three confirmed code defects, not yet filed as issues
+
+Found while verifying this repository's own claims about itself, and confirmed by reading the exact
+lines named:
+
+- `views/Chart.jsx` line 112 checks `props.suggestedYMax`, but line 113 reads
+ `props.suggestedMax` — a prop name that does not exist anywhere else in the codebase. This is
+ currently latent because no live metric sets a `suggestedYMax`, but it will silently produce the
+ wrong chart y-axis maximum the moment one does.
+- `views/SummaryMetric.jsx` line 46 sets `this.arrowIgnoreThreshold = 4` in the constructor, but
+ line 131 reads `this.ignoreThreshold` — a property that is never set anywhere. The "hide the
+ arrow on small bars" logic this line implements has never worked.
+- `views/SummaryMetric.jsx` line 67 defines a `_handleResize` method that is never attached to any
+ event listener anywhere in the file (confirmed: no `addEventListener` call exists in it at all).
+ The four hardware summary bars this component renders do not respond to window resize.
+
+None of these three are filed as GitHub issues as of this writing. Whoever picks one up should file
+it as an issue first — these are exactly the kind of finding that belongs in the tracker, not as a
+permanent list in this file, for the same reason the wider issue backlog isn't summarized in
+`system-overview.md`.
+
+## File layout
+
+ src/
+ ├── index.jsx entry: ReactDOM.render + BrowserRouter
+ ├── config.json the route/dashboard table + next-button flow (above)
+ ├── registerServiceWorker.js only unregister() is called
+ ├── setupTests.js enzyme adapter; puts React and shallow on the global object
+ ├── components/
+ │ ├── containers/ 6 stateful, data-fetching .jsx files (above)
+ │ ├── decorators/ withTracker.jsx (react-ga), withNextButton.jsx
+ │ └── views/ 19 presentational .jsx files
+ │ ├── styl/ Stylus source, hand-edited; includes/lib.styl holds shared variables
+ │ ├── css/ generated from styl/, gitignored — see CONTRIBUTING.md
+ │ ├── fonts/FiraSans/ 8 weights/styles of webfonts
+ │ └── img/ 3 assets
+ ├── lib/
+ │ ├── lazyLoad.jsx wraps a dynamic import() in React.lazy
+ │ ├── LazyBoundary.jsx error boundary + Suspense fallback, paired with lazyLoad.jsx
+ │ └── utils.js bumpSort, isFloat, prettifyNumber, getPageTitle
+ └── tests/
+ └── jest/ 2 unit test files, run by Vitest (see CONTRIBUTING.md)
+
+ tests/
+ └── playwright/ end-to-end specs, run by Playwright (see CONTRIBUTING.md);
+ deliberately at the repository root, not under src/
+
+There is no `pages/`, `store/`, `hooks/`, `api/`, or `locales/` directory — this is a small enough
+application that none has been needed. `public/` holds static files served as-is (`manifest.json`,
+`contribute.json`, `img/`); `index.html` itself lives at the repository root, Vite's convention.
+`build/` is gitignored output. Routes are exactly `/`, `/contact`, `/dashboard/`, and a
+catch-all not-found page.
+
+## The dependency ceiling on modernizing this application
+
+Worth knowing before attempting any React upgrade: confirmed by reading their installed
+`package.json` files directly, both `react-metrics-graphics` (this application's charting library)
+and `react-refetch` (its entire data-fetching layer, described above) declare a `peerDependencies`
+constraint that caps React at version 16 (`^15||^16` and an equivalent range respectively). Neither
+has shipped a release since 2019, and the charting library's own upstream, `metrics-graphics`, has
+been stuck on a never-promoted 3.0 beta since 2022.
+
+**This application cannot move past React 16 without first replacing its chart-rendering and
+data-fetching layers.** That is a legitimate, large, separate initiative in its own right — not
+something to bundle into routine maintenance or into any build-tooling change. Any plan touching
+this application's build tooling should leave the React version, `react-router-dom`, and
+`react-refetch` untouched, and say so explicitly, rather than assume they're in scope.
diff --git a/docs/execplans/2026-09-15-update-toolchain.md b/docs/execplans/2026-09-15-update-toolchain.md
new file mode 100644
index 00000000..edf54f8c
--- /dev/null
+++ b/docs/execplans/2026-09-15-update-toolchain.md
@@ -0,0 +1,1114 @@
+# Modernize the build, test, and lint toolchain so the site runs on Node 24
+
+This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`,
+`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. This
+document must be maintained in accordance with the specification checked in at
+`.claude/skills/execplans/references/PLANS.md`, including its "Ensemble addendum" section, which
+overrides the upstream text where the two disagree.
+
+## Purpose / Big Picture
+
+Today, a fresh checkout of this repository cannot be built or fully installed with a plain `npm
+install` on the most recent stable version of Node (Node 24.19.0), and its end-to-end
+test suite cannot run at all. Two specific, confirmed failures cause this: `chromedriver@84.0.1`
+(a transitive dependency of the `nightwatch` end-to-end test runner) fails its install step
+outright on Apple Silicon with "Only Mac 64 bits supported", and `react-scripts` (the
+`create-react-app` build tool, referred to below by its common abbreviation "CRA") produces a
+build tool ("webpack 4") old enough that it calls into an OpenSSL API Node removed, failing with
+`ERR_OSSL_EVP_UNSUPPORTED` unless the developer remembers to set
+`NODE_OPTIONS=--openssl-legacy-provider` on every build and every dev-server start. Both of these
+are confirmed by running the actual commands in "Concrete Steps" below, on this machine, today.
+
+After this plan, a developer will be able to clone this repository, run `npm install` with no
+special flag, run `npm start` with no environment variable workaround, run `npm run build:app`
+with no environment variable workaround, run the unit tests, and run the full end-to-end test
+suite — all on Node 24, all without any of the four blockers above. Concretely: `create-react-app`
+is replaced with Vite (a modern build tool and dev server), Jest (which only worked before because
+`react-scripts` quietly configured it) is replaced with Vitest (a test runner built on the same
+engine as Vite), Nightwatch is replaced with Playwright (a modern end-to-end test runner that
+manages its own browser binaries instead of depending on a separately-versioned `chromedriver`
+package), and the two previously-incomplete ESLint configurations are unified into one. This is
+exactly the scope of GitHub issue #441, "Update toolchain"
+(), whose body reads in full: "As a first step to
+doing any work on this code base, let's modernize the tool chain: CRA → Vite, Jest → Vitest,
+Nightwatch → Playwright." Separately, this plan also brings a set of this repository's other
+dependencies up to date where doing so is safe without touching application code — see Milestone
+5.
+
+What this plan deliberately does not do: it does not upgrade React itself (currently 16.13.1), does
+not touch `react-router-dom`'s major version (currently 5.2.0), and does not replace
+`react-refetch` or the `metrics-graphics`/`react-metrics-graphics` charting stack. All three are
+verified below (see "Context and Orientation") to be hard-capped to React's 15/16 line by their own
+`package.json` `peerDependencies` — upgrading React is a separate, much larger initiative that
+would require replacing the data-fetching layer and the charting library at the same time, not a
+toolchain change.
+
+A person reading only this file, with only a working copy of this repository and no other memory of
+this conversation, should be able to execute every milestone below and end up with a repository
+that passes the acceptance criteria in "Validation and Acceptance."
+
+## Progress
+
+- [x] (2026-09-15) Read `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, and
+ `.claude/skills/execplans/references/PLANS.md` to establish this repository's conventions.
+- [x] (2026-09-15) Found and read GitHub issue #441, "Update toolchain," which states this
+ plan's scope exactly (CRA → Vite, Jest → Vitest, Nightwatch → Playwright).
+- [x] (2026-09-15) Discovered a local, unpushed branch named `toolchain` that already contains a
+ complete implementation of this same migration, and validated it hands-on in an isolated
+ `git worktree` (not the working tree of this branch): a plain `npm install` completed with
+ no `--ignore-scripts` flag and no chromedriver failure, `npx vite build` succeeded with no
+ `NODE_OPTIONS` flag, `npx vitest run` passed both existing unit test files, `npm run lint`
+ passed cleanly, the Vite dev server served the application, and `npx playwright test --list`
+ correctly enumerated 149 ported end-to-end tests across 12 files. Presented this discovery to
+ the user, who explicitly chose to start this plan's implementation fresh rather than
+ cherry-pick or merge that branch's commits (see Decision Log). The `toolchain` branch is left
+ untouched and is not depended on by anything below.
+- [x] (2026-09-15) Created branch `441--update-toolchain` off `clean-up` (the branch active in this
+ working tree at the time), per the user's explicit choice (see Decision Log).
+- [x] (2026-09-15) Verified today's baseline on this branch, on this machine (Node v24.19.0, npm
+ 11.17.0): a fresh `npm install --ignore-scripts` installs successfully (2007 packages, 251
+ vulnerabilities: 12 low, 136 moderate, 82 high, 21 critical); `npm run build:app` with no
+ flag fails with `ERR_OSSL_EVP_UNSUPPORTED`; `react-metrics-graphics`'s and `react-refetch`'s
+ `peerDependencies` both cap React to the 15/16 line; `react-loadable` (via
+ `src/lib/lazyLoad.jsx`) is used at 7 call sites, not the 2 previously assumed elsewhere,
+ across `src/components/views/Main.jsx` (4 sites) and `src/components/views/MetricOverview.jsx`
+ (3 sites); the 21 critical `npm audit` findings trace, via `fixAvailable` paths and direct
+ package names, overwhelmingly to `nightwatch`, `chromedriver`'s dependency tree, and
+ `react-scripts`, with the remainder resolvable once those are gone (see "Concrete Steps" for
+ the full command and output). Full details of every check are recorded in "Context and
+ Orientation" and "Concrete Steps" below.
+- [x] (2026-09-15) Looked up current published versions and Node engine requirements for every
+ package this plan adds, recorded in "Interfaces and Dependencies" below. Notably, Vitest
+ 5.0.1's own `engines.node` field is `^22.12.0 || ^24.0.0 || >=26.0.0` — narrower than Vite's,
+ ESLint's, or Playwright's own requirements — which is why this plan pins `package.json`'s
+ `engines.node` to `^24.0.0` rather than a broader range (see Decision Log).
+- [x] (2026-09-15) Milestone 1 complete: `react-scripts`, `react-app-polyfill`, and `babel-polyfill`
+ removed; `vite` 8.3.0 and `@vitejs/plugin-react` 6.1.1 added; `stylus` bumped from 0.54.7 to
+ 0.64.0 ahead of the Milestone 5 schedule (a hard prerequisite discovered during this
+ milestone, see Surprises & Discoveries); `index.html` moved to the repository root and
+ adapted; `src/index.jsx` and `src/components/views/Application.jsx` no longer reference IE11
+ scaffolding; every `REACT_APP_*` reference renamed to `VITE_*` **and** switched from
+ `process.env.X` to `import.meta.env.X` (a correction to this plan's original wording, which
+ only mentioned the rename — see Surprises & Discoveries); `package.json` updated (`engines`,
+ scripts, `size`, `browserslist` removed); `.nvmrc` added; `react-loadable` replaced with
+ `React.lazy`/`Suspense` plus a new `src/lib/LazyBoundary.jsx` error-boundary component, after
+ hands-on browser verification showed the direct port genuinely does break (see Surprises &
+ Discoveries) — this was anticipated as a documented contingency in this plan's original Plan
+ of Work, not a new decision. Verified with a real headless-browser session (Playwright,
+ installed ad hoc, not yet a project dependency — that happens in Milestone 4): every route
+ (`/`, `/contact`, `/dashboard/hardware`, `/dashboard/usage-behavior`,
+ `/dashboard/user-activity`) renders against the production build (`vite build`) with zero
+ console errors and real chart data (15, 2, and 5 `