diff --git a/.claude/skills/execplans/references/PLANS.md b/.claude/skills/execplans/references/PLANS.md
index bfe9fde4..d96cf8af 100644
--- a/.claude/skills/execplans/references/PLANS.md
+++ b/.claude/skills/execplans/references/PLANS.md
@@ -21,6 +21,7 @@ NON-NEGOTIABLE REQUIREMENTS:
* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
+* Every milestone must assess whether its own changes leave any documentation or code comment stale, and update it as part of that milestone — never deferred to a separate final step. See "Milestones" below.
* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
* Every ExecPlan must define every term of art in plain language or do not use it.
@@ -54,7 +55,7 @@ Capture evidence. When your steps produce terminal output, short diffs, or logs,
## Milestones
-Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
+Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, the acceptance you expect to observe, and whether that milestone's own changes leave any documentation or code comment stale — update it within the same milestone, or state plainly that nothing needed updating. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
@@ -119,7 +120,7 @@ Prefer additive code changes followed by subtractions that keep tests passing. P
## Plan of Work
- Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
+ Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal. If the work is broken into milestones, each milestone's description must say whether it leaves documentation or code comments stale and what was done about it (see "Milestones" above).
## Concrete Steps
diff --git a/.env b/.env
index 721156e5..e4e8be84 100644
--- a/.env
+++ b/.env
@@ -1,7 +1,3 @@
-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..e448ce28
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,295 @@
+# 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
+ npx playwright install chromium # one-time browser download; needed before test:playwright
+
+ 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 67d07d1b..122b8e69 100644
--- a/README.md
+++ b/README.md
@@ -9,38 +9,29 @@ See [`docs/architecture/data-pipeline.md`](docs/architecture/data-pipeline.md)
for how that data is generated upstream, where it lives, and which
repositories are involved.
-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
-See the [create-react-app documentation on
-deployment](https://facebook.github.io/create-react-app/docs/deployment).
+Run `npm run build:app` to produce a static build in `build/` (see `CONTRIBUTING.md` for the full
+command reference). How that build is deployed is not documented in this repository.
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..0518541b
--- /dev/null
+++ b/docs/execplans/2026-09-15-update-toolchain.md
@@ -0,0 +1,1256 @@
+# 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 `