diff --git a/.cursor/rules/adding-new-fixtures.mdc b/.cursor/rules/adding-new-fixtures.mdc
index 1e6319b036..b17d010848 100644
--- a/.cursor/rules/adding-new-fixtures.mdc
+++ b/.cursor/rules/adding-new-fixtures.mdc
@@ -1,192 +1,159 @@
+---
+description: Adding New Fixtures to Benchmarks
+globs: suite/fixtures/**,suite/scripts/**,packages/types/src/**,.github/workflows/**
+---
# Adding New Fixtures to Benchmarks
-Comprehensive guide for adding new fixture types to the vlt benchmarks suite.
+Checklist for adding a fixture to the benchmark suite. The explanation, with the
+verification commands and the reasoning, is in
+`docs/FIXTURES/ADDING-A-FIXTURE.md` — keep the two in step.
name: adding_new_fixtures
-description: Complete checklist and procedures for adding new benchmark fixtures
+description: Checklist for adding a benchmark fixture, including the vlt.json step whose omission silently measures the wrong project
filters:
- # Match fixture-related files and directories
- type: path
- pattern: "^fixtures/"
+ pattern: "^suite/fixtures/"
- type: path
- pattern: "scripts/(generate-chart\\.js|process-results\\.sh|benchmark\\.sh)"
+ pattern: "^suite/fixtures/.*/package\\.json$"
- type: path
- pattern: "app/src/(types|hooks|lib|components)/"
+ pattern: "^suite/fixtures/.*/vlt\\.json$"
- type: path
- pattern: "\\.github/workflows/"
- # Match package.json files in fixtures
+ pattern: "suite/scripts/(generate-chart\\.js|process-results\\.sh|benchmark\\.sh)"
+ - type: path
+ pattern: "^packages/types/src/"
- type: path
- pattern: "fixtures/.*/package\\.json$"
+ pattern: "\\.github/workflows/"
actions:
- type: suggest
message: |
- ## Adding a New Fixture Checklist
+ ## Adding a fixture: checklist
- When adding a new fixture (e.g., "my-fixture"), follow these steps:
+ Full reasoning: `docs/FIXTURES/ADDING-A-FIXTURE.md`.
- ### 1. Create Fixture Directory and Configuration
- ```bash
- mkdir -p fixtures/my-fixture
- ```
-
- Create `fixtures/my-fixture/package.json` with:
- - Appropriate dependencies for your test case
- - Valid npm package.json structure
- - Consider the fixture's purpose (small, large, specific framework, etc.)
-
- ### 2. Update Scripts (Data Processing)
-
- **File: `scripts/generate-chart.js`**
- ```javascript
- // Line ~54: Add fixture to the fixtures array
- const fixtures = ["next", "astro", "svelte", "vue", "large", "my-fixture", "run"];
+ ### 0. `vlt.json` — DO NOT SKIP THIS
+
+ Every fixture directory needs its own `vlt.json`:
+
+ ```json
+ { "config": { "registries": { "npm": "https://registry.vlt.io/vlt-benchmarks/npm/" } } }
```
- **File: `scripts/process-results.sh`**
+ It points installs at the benchmark registry mirror AND makes the fixture
+ directory a vlt project root.
+
+ **Without it, vlt walks up and resolves the REPOSITORY ROOT as the
+ project.** The install then operates on the monorepo's dependency graph
+ instead of the fixture's. There is no error — you get numbers, and they
+ measure the wrong thing. Verified: from a directory with a `package.json`
+ and no `vlt.json`, `vlt query ':root'` returns the `benchmarks` root
+ package at `location: "."`.
+
+ If the fixture is itself a monorepo, declare its workspaces here too —
+ `suite/fixtures/babylon/vlt.json` is the worked example.
+
+ ### 1. Create the fixture
+
```bash
- # Line ~55: Add fixture to the processing loop
- for fixture in next astro svelte vue large my-fixture; do
+ mkdir -p suite/fixtures/
+ # then: package.json AND vlt.json
```
- ### 3. Update TypeScript Types
+ `` becomes part of every published filename as
+ `{fixture}-{variation}.json`, and the parser splits on the FIRST `-`, so
+ the name must not contain a hyphen. Use kebab-case otherwise.
- **File: `app/src/types/chart-data.ts`**
- ```typescript
- // Line ~13: Add to Fixture type union
- export type Fixture = "next" | "astro" | "svelte" | "vue" | "large" | "my-fixture" | "run";
- ```
+ ### 2. Suite enumerations — TWO occurrences in each file
- ### 4. Update React Application
-
- **File: `app/src/hooks/use-package-count-data.ts`**
- ```typescript
- // Lines ~26-33: Add to fixtures array
- const fixtures: Fixture[] = sortFixtures([
- "next",
- "astro",
- "svelte",
- "vue",
- "large",
- "my-fixture",
- "run",
- ]);
- ```
+ | File | Occurrences |
+ | --- | --- |
+ | `suite/scripts/generate-chart.js` | **2** arrays (one includes `run`, one does not) |
+ | `suite/scripts/process-results.sh` | **2** loops (both exclude `run`) |
- **File: `app/src/lib/utils.ts`**
- ```typescript
- // Line ~334: Add to sortFixtures preferredOrder
- const preferredOrder: Fixture[] = ["next", "vue", "svelte", "astro", "large", "my-fixture", "run"];
- ```
+ Updating only the first occurrence yields a partial integration that
+ still exits 0.
- ### 5. Update GitHub Actions Workflow
-
- **File: `.github/workflows/benchmark.yaml`**
- ```yaml
- # Line ~8: Update default fixtures list
- default: '["next", "astro", "vue", "svelte", "large", "my-fixture"]'
-
- # Line ~36: Update matrix fixture list
- fixture: ${{ fromJson(inputs.fixtures || '["next", "astro", "vue", "svelte", "large", "my-fixture"]') }}
-
- # Lines ~102-108: Update JavaScript fixtures array
- const fixtures = [
- "next",
- "astro",
- "vue",
- "svelte",
- "large",
- "my-fixture",
- "run"
- ];
- ```
+ ### 3. Published types
- ### 6. Update Icon Support (Optional)
-
- **File: `app/src/lib/get-icons.ts`**
- ```typescript
- // Import appropriate icon or use existing one
- import { MyFixtureIcon } from "@/components/icons/index";
-
- // Add to frameworkMap
- const frameworkMap: Partial> = {
- astro: Astro,
- next: Next,
- svelte: Svelte,
- vue: Vue,
- large: Package,
- "my-fixture": MyFixtureIcon, // or reuse existing icon
- };
- ```
+ `packages/types/src/index.ts` — add to **both** the `Fixture` union and
+ the `FIXTURES` array. A `satisfies` clause ties them together, so missing
+ one is a type error rather than a silent gap.
- ### 7. Update Documentation
-
- **File: `README.md`**
- ```markdown
- ### Project Types (fixtures)
- - Next.js
- - Astro
- - Svelte
- - Vue
- - Large
- - My Fixture (description of what this fixture tests)
- ```
+ ### 4. Workflow — THREE places in `.github/workflows/benchmark.yaml`
- ### 8. Verification Steps
+ | Line (approx.) | What |
+ | --- | --- |
+ | 8 | the `fixtures` input default |
+ | 98 | the literal the partial-run gate string-compares against |
+ | 119 | the matrix `fixture` list |
- After making all changes:
+ Line 98 is the trap. The workflow decides a dispatch is a "partial run" —
+ and therefore must not deploy — by comparing the input against the
+ default list as a string. Change the default without changing that
+ literal and every dispatch is classified partial, so results silently
+ stop publishing while every job stays green.
- 1. **Lint Check**: Run linting on modified TypeScript files
- 2. **Directory Check**: Verify fixture directory exists with package.json
- 3. **Build Test**: Test that the app builds successfully
- 4. **Workflow Test**: Verify GitHub Actions workflow accepts the new fixture
+ Decide separately whether the fixture joins the reduced set that runs on
+ pushes to non-`main` branches. `large` and `babylon` are excluded there
+ because they are slow.
- ### 9. Fixture Design Considerations
+ ### 5. Documentation
- When creating the fixture's `package.json`:
-
- - **Small fixtures**: 5-15 dependencies (testing basic scenarios)
- - **Medium fixtures**: 20-50 dependencies (realistic projects)
- - **Large fixtures**: 50+ dependencies (stress testing)
- - **Framework fixtures**: Use actual framework templates
- - **Specific use cases**: Target particular scenarios (monorepo, TypeScript, etc.)
+ - `docs/FIXTURES/.md`, following the existing pages
+ - a row in `docs/FIXTURES/README.md`
+ - the fixture list in the root `README.md`
- ### 10. Common Pitfalls
+ ### 6. What you do NOT need to touch
- ⚠️ **Don't forget to update ALL locations**:
- - Missing GitHub Actions updates will cause CI failures
- - Missing TypeScript type updates will cause build errors
- - Missing script updates will exclude fixture from processing
- - Missing icon mapping will show no icon in UI
+ `apps/www` discovers fixtures from the generated manifest at runtime, so
+ the frontend needs no per-fixture change. `apps/data` validates fixture
+ names against `FIXTURES`, so step 3 covers it.
- ⚠️ **Maintain consistent ordering**:
- - Keep the same fixture order across all files
- - Follow the established naming conventions
- - Use kebab-case for fixture directory names
+ Older versions of this rule listed files under `app/src/` — those
+ instructions are obsolete. That app is parked at `tmp/app/`.
+
+ ### 7. Verify
+
+ ```bash
+ # resolves to itself, not the repository root
+ cd suite/fixtures/ && vlt install && vlt query ':root' --view=json
+
+ # one short run, from the repo root
+ ./suite/bench run --fixtures= --variation=clean --runs=1
+ ```
+
+ Then clean up before committing. A run leaves
+ `suite/fixtures//package-lock.json`, which DOES show as untracked,
+ and `suite/fixtures//node_modules/`, which is gitignored and shows
+ nowhere:
+
+ ```bash
+ git status --porcelain suite/fixtures/
+ rm -rf suite/fixtures//node_modules suite/fixtures//package-lock.json
+ ```
examples:
- input: |
- # Example: Adding a "typescript" fixture
-
- 1. Create fixtures/typescript/package.json
- 2. Update all 7+ files mentioned above
- 3. Use TypeScript icon or create new one
- 4. Update README with description
-
- output: "Complete fixture integration following all steps"
+ # Adding a "remix" fixture
+ suite/fixtures/remix/package.json
+ suite/fixtures/remix/vlt.json <- without this, wrong numbers
+ suite/scripts/generate-chart.js <- 2 arrays
+ suite/scripts/process-results.sh <- 2 loops
+ packages/types/src/index.ts <- Fixture union + FIXTURES
+ .github/workflows/benchmark.yaml <- lines 8, 98, 119
+ docs/FIXTURES/REMIX.md + README rows
+ output: "Complete fixture integration"
metadata:
priority: high
- version: 1.0
- last_updated: "2024-09-25"
+ version: 2.0
+ last_updated: "2026-09-30"
related_files:
- - "scripts/generate-chart.js"
- - "scripts/process-results.sh"
- - "app/src/types/chart-data.ts"
- - "app/src/hooks/use-package-count-data.ts"
- - "app/src/lib/utils.ts"
+ - "docs/FIXTURES/ADDING-A-FIXTURE.md"
+ - "suite/scripts/generate-chart.js"
+ - "suite/scripts/process-results.sh"
+ - "packages/types/src/index.ts"
- ".github/workflows/benchmark.yaml"
- - "app/src/lib/get-icons.ts"
- "README.md"
-
\ No newline at end of file
+
diff --git a/.cursor/rules/keeping-docs-current.mdc b/.cursor/rules/keeping-docs-current.mdc
new file mode 100644
index 0000000000..d5aeefbbc6
--- /dev/null
+++ b/.cursor/rules/keeping-docs-current.mdc
@@ -0,0 +1,109 @@
+---
+description: Keeping docs/ current
+globs: docs/**,suite/**,apps/**,packages/**,.github/workflows/**,README.md
+---
+# Keeping `docs/` current
+
+`docs/` is the answer to "nobody knows how to run this thing locally or how it
+works in production". It only stays the answer if changes that invalidate it
+update it in the same commit.
+
+
+name: keeping_docs_current
+description: Which documents a change invalidates, and the rule that documented commands must be run
+filters:
+ - type: path
+ pattern: "^docs/"
+ - type: path
+ pattern: "^suite/"
+ - type: path
+ pattern: "^apps/"
+ - type: path
+ pattern: "^packages/"
+ - type: path
+ pattern: "\\.github/workflows/"
+ - type: path
+ pattern: "^README\\.md$"
+
+actions:
+ - type: suggest
+ message: |
+ ## Does this change invalidate a document?
+
+ | If you changed | Update |
+ | --- | --- |
+ | a variation, fixture, or anything about what is measured | `docs/METHODOLOGY.md` |
+ | the shape of published data | `docs/METHODOLOGY.md` **and** `packages/types` |
+ | a fixture | its `docs/FIXTURES/.md`, the table in `docs/FIXTURES/README.md`, the root `README.md` |
+ | added or removed a fixture | also `docs/FIXTURES/ADDING-A-FIXTURE.md` and `.cursor/rules/adding-new-fixtures.mdc` |
+ | a local command, script, or env var | `docs/GET-STARTED/DEVELOPMENT.md` |
+ | `apps/data`'s endpoints or client methods | `docs/METHODOLOGY.md` and `docs/GET-STARTED/DEVELOPMENT.md` |
+ | a workflow, deploy step, or gh-pages behaviour | `docs/GET-STARTED/PRODUCTION.md` |
+ | **`runs-on`, or anything about the machine** | the **Environment** section of the root `README.md` — it states the hardware the published numbers were produced on, and it said `ubuntu-latest` for the whole time the suite ran on arm |
+ | `apps/www` — charts, tokens, components, or an honesty behaviour | `docs/FRONTEND.md` |
+ | workspace layout, catalogs, or required scripts | `docs/PACKAGE-CONTRACT.md` |
+
+ ## Three rules
+
+ ### 1. Every documented command must have been run
+
+ Not "should work". Run it, confirm the exit code, and prefer pasting real
+ output over describing it. This documentation exists because the previous
+ instructions had drifted into being wrong while still looking plausible.
+
+ ### 2. A silent failure is worth more words than a feature
+
+ This repository's signature failure is a command that exits 0 having done
+ the wrong thing or nothing: `bench process` reprocessing stale output,
+ `test` passing with a type error, a fixture without `vlt.json` measuring
+ the repository, `NEXT_PUBLIC_*` ignoring a changed env var. When you find
+ another, add it to
+ `docs/GET-STARTED/DEVELOPMENT.md#traps-that-cost-people-an-afternoon`
+ with the real terminal output and the exit code.
+
+ State the blast radius too. "CI is not exposed to this" is as important as
+ the trap, or a reader draws a bigger conclusion than the evidence supports.
+
+ ### 3. Do not duplicate; link
+
+ Each fact lives in one document. `docs/PACKAGE-CONTRACT.md` owns the
+ workspace contract, `docs/METHODOLOGY.md` owns the data contract,
+ `docs/GET-STARTED/*` own the workflows. A second copy is a second thing to
+ go stale. Cross-reference with a relative link and check it resolves.
+
+ ## When a path in the frozen suite is wrong
+
+ `suite/` is frozen: changing it changes what the benchmarks measure. Some
+ strings it *prints* are stale — `bench:73`, `bench:352`,
+ `download-latest-artifacts.sh:178-183`, `generate-chart.js:6` all name an
+ `app/latest` path that no longer applies.
+
+ Do not edit them to fix documentation. Correct the path in `docs/` and
+ make sure the stale-strings table in
+ `docs/GET-STARTED/DEVELOPMENT.md#stale-printed-guidance-inside-the-suite`
+ lists it, so the printed text is not the only answer a reader finds.
+
+examples:
+ - input: |
+ # Added a client method to apps/data
+ apps/data/src/client.ts
+ docs/METHODOLOGY.md <- endpoint table
+ docs/GET-STARTED/DEVELOPMENT.md <- endpoint table
+ output: "Change and its documentation in one commit"
+ - input: |
+ # Found a command that exits 0 while doing nothing
+ docs/GET-STARTED/DEVELOPMENT.md <- traps section, with real output,
+ exit code, and blast radius
+ output: "Trap documented with evidence"
+
+metadata:
+ priority: high
+ version: 1.0
+ last_updated: "2026-09-30"
+ related_files:
+ - "docs/METHODOLOGY.md"
+ - "docs/GET-STARTED/DEVELOPMENT.md"
+ - "docs/GET-STARTED/PRODUCTION.md"
+ - "docs/PACKAGE-CONTRACT.md"
+ - "docs/FIXTURES/README.md"
+
diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml
index e435d1b43f..e7f0777189 100644
--- a/.github/workflows/benchmark.yaml
+++ b/.github/workflows/benchmark.yaml
@@ -32,7 +32,7 @@ jobs:
name: "Detect Changes"
runs-on: ubuntu-24.04-arm
outputs:
- app_only: ${{ steps.check.outputs.app_only }}
+ skip_benchmarks: ${{ steps.check.outputs.skip_benchmarks }}
partial_run: ${{ steps.partial.outputs.partial_run }}
steps:
- uses: actions/checkout@v6
@@ -43,7 +43,7 @@ jobs:
run: |
# For scheduled runs and manual triggers, always run benchmarks
if [[ "${{ github.event_name }}" != "push" ]]; then
- echo "app_only=false" >> "$GITHUB_OUTPUT"
+ echo "skip_benchmarks=false" >> "$GITHUB_OUTPUT"
echo "Non-push event (${{ github.event_name }}), running full benchmarks"
exit 0
fi
@@ -52,7 +52,7 @@ jobs:
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
if [ -z "$CHANGED_FILES" ]; then
- echo "app_only=false" >> "$GITHUB_OUTPUT"
+ echo "skip_benchmarks=false" >> "$GITHUB_OUTPUT"
echo "Could not determine changed files, running full benchmarks"
exit 0
fi
@@ -60,24 +60,36 @@ jobs:
echo "Changed files:"
echo "$CHANGED_FILES"
- # Check if ALL changed files are under app/ (or non-benchmark files like README, .editorconfig)
- APP_ONLY=true
+ # skip_benchmarks=true means: nothing benchmark-relevant changed in
+ # this push, so there is nothing to run. It was called app_only until
+ # the deploy-app job was removed; with no app to deploy, the name was
+ # a lie but the logic is unchanged and the truth table is identical.
+ #
+ # The allowlist below is everything OUTSIDE the benchmark suite: the
+ # apps, packages, docs and the parked tmp/app, plus non-benchmark
+ # files like README and .editorconfig.
+ #
+ # It FAILS OPEN, and that is the property that makes it safe: any
+ # path not matched falls through to *) and runs the full matrix. A
+ # new top-level directory therefore costs a benchmark run rather than
+ # silently skipping one. Do not change that by adding a catch-all.
+ SKIP_BENCHMARKS=true
while IFS= read -r file; do
case "$file" in
- app/*|README.md|.editorconfig|.cursor/*|.github/workflows/*)
+ apps/*|packages/*|docs/*|tmp/*|README.md|.editorconfig|.cursor/*|.github/workflows/*)
# App or non-benchmark files — safe to skip benchmarks
;;
*)
# Benchmark-relevant file changed
- APP_ONLY=false
+ SKIP_BENCHMARKS=false
echo "Benchmark-relevant file changed: $file"
break
;;
esac
done <<< "$CHANGED_FILES"
- echo "app_only=$APP_ONLY" >> "$GITHUB_OUTPUT"
- echo "App-only changes: $APP_ONLY"
+ echo "skip_benchmarks=$SKIP_BENCHMARKS" >> "$GITHUB_OUTPUT"
+ echo "Nothing benchmark-relevant changed: $SKIP_BENCHMARKS"
- name: Detect partial run
id: partial
env:
@@ -107,7 +119,7 @@ jobs:
name: "Run Benchmarks"
runs-on: ubuntu-24.04-arm
needs: [detect-changes]
- if: needs.detect-changes.outputs.app_only != 'true'
+ if: needs.detect-changes.outputs.skip_benchmarks != 'true'
timeout-minutes: 120
environment: Benchmark
strategy:
@@ -139,6 +151,10 @@ jobs:
node-version: "24"
package-manager-cache: false
- name: Install & Setup Tools
+ # setup.sh writes ./results/versions.json relative to cwd, and bench
+ # resolves results/ against its own directory. Both must agree on
+ # suite/, so this runs from there rather than via a path prefix.
+ working-directory: suite
run: |
bash ./scripts/setup.sh
- name: Configure AWS Credentials
@@ -201,7 +217,7 @@ jobs:
BENCH_WARMUP: ${{ steps.tune.outputs.warmup }}
BENCH_RUNS: ${{ steps.tune.outputs.runs }}
run: |
- ./bench run \
+ ./suite/bench run \
--fixtures="${{ matrix.fixture }}" \
--variation="${{ matrix.variation }}"
- name: Upload Benchmark Results
@@ -209,20 +225,20 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: results-${{ matrix.fixture }}-${{ matrix.variation }}
- path: ./results/${{ matrix.fixture }}/${{ matrix.variation }}/
+ path: ./suite/results/${{ matrix.fixture }}/${{ matrix.variation }}/
retention-days: 7
- name: Upload Versions Info
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: versions-${{ matrix.fixture }}-${{ matrix.variation }}
- path: ./results/versions.json
+ path: ./suite/results/versions.json
retention-days: 7
process:
name: "Process Results"
runs-on: ubuntu-24.04-arm
needs: [detect-changes, benchmark]
- if: needs.detect-changes.outputs.app_only != 'true'
+ if: needs.detect-changes.outputs.skip_benchmarks != 'true'
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
@@ -233,12 +249,12 @@ jobs:
- name: Download Results
uses: actions/download-artifact@v8
with:
- path: results
+ path: suite/results
pattern: results-*
- name: Download Versions
uses: actions/download-artifact@v8
with:
- path: versions-temp
+ path: suite/versions-temp
pattern: versions-*
- name: Scan Raw Registry Results
id: registry-raw
@@ -254,10 +270,10 @@ jobs:
const path = require('path')
const failures = []
- for (const entry of fs.readdirSync('results').sort()) {
+ for (const entry of fs.readdirSync('suite/results').sort()) {
const match = /^results-(.+)-(registry-[a-z]+)$/.exec(entry)
if (!match) continue
- const file = path.join('results', entry, 'benchmarks.json')
+ const file = path.join('suite/results', entry, 'benchmarks.json')
if (!fs.existsSync(file)) continue
const label = `${match[1]} ${match[2]}`
@@ -278,8 +294,13 @@ jobs:
core.setOutput('vlt_failures', failures.join('\n'))
- name: Process Results
run: |
- ./bench process
+ ./suite/bench process
- name: Summarize results in log
+ # Reads the raw per-matrix artifact directories, which live under the
+ # suite. The node snippet also indexes the path by position
+ # (p.split("/")[1]), so running from suite/ keeps it correct without
+ # rewriting the snippet.
+ working-directory: suite
run: |
{
echo '```'
@@ -297,20 +318,31 @@ jobs:
done
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
+ - name: Checkout gh-pages data
+ # The data index must describe the WHOLE published tree, not just this
+ # run. keep_files merges, so an index generated from results/ alone
+ # would replace the published one with a partial view.
+ uses: actions/checkout@v6
+ with:
+ ref: gh-pages
+ path: gh-pages-data
- name: Install vlt
run: |
npm install -g vlt@latest
- - name: Build Charts View
+ - name: Generate Data Index
+ # apps/data emits index.json, manifest.json and the manifest/ shards
+ # into suite/results/, the single results tree. It never re-emits the
+ # published date directories (decision D2) and never writes an
+ # index.html -- the published index.html is the live site at
+ # benchmarks.vlt.sh and survives via keep_files.
run: |
- pushd app
vlt install
- vlt run build
- popd
+ vlt run --workspace=apps/data build
- name: Upload Processed Results
uses: actions/upload-artifact@v7
with:
name: results
- path: results/
+ path: suite/results/
retention-days: 7
- name: Check Registry Results
id: registry-check
@@ -333,7 +365,7 @@ jobs:
// alert fires exactly when vlt appears slower on the site.
// Registry variations are the only ones where `command` is a
// registry name rather than a package manager.
- const dir = 'results/latest'
+ const dir = 'suite/results/latest'
for (const entry of fs.readdirSync(dir).sort()) {
const match = /^(.+)-(registry-[a-z]+)\.json$/.exec(entry)
if (!match) continue
@@ -386,59 +418,19 @@ jobs:
needs: [detect-changes, process]
permissions:
contents: write
- if: github.ref == 'refs/heads/main' && needs.detect-changes.outputs.app_only != 'true' && needs.detect-changes.outputs.partial_run != 'true'
+ if: github.ref == 'refs/heads/main' && needs.detect-changes.outputs.skip_benchmarks != 'true' && needs.detect-changes.outputs.partial_run != 'true'
steps:
- uses: actions/checkout@v6
- name: Download Results
uses: actions/download-artifact@v8
with:
name: results
- path: results/
- - name: Deploy to GitHub Pages
- uses: peaceiris/actions-gh-pages@v4
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: results
- keep_files: true
-
- deploy-app:
- name: "Deploy App Only"
- runs-on: ubuntu-24.04-arm
- needs: [detect-changes]
- permissions:
- contents: write
- if: github.ref == 'refs/heads/main' && needs.detect-changes.outputs.app_only == 'true'
- steps:
- - uses: actions/checkout@v6
- - name: Install Node
- uses: actions/setup-node@v6
- with:
- node-version: "24"
- - name: Checkout gh-pages data
- uses: actions/checkout@v6
- with:
- ref: gh-pages
- path: gh-pages-data
- - name: Install vlt
- run: |
- npm install -g vlt@latest
- - name: Build App
- run: |
- pushd app
- vlt install
- vlt run build
- popd
- - name: Prepare deploy directory
- run: |
- # Copy the latest data files from gh-pages into the built results
- cp -r gh-pages-data/latest results/latest
- # Copy index.html and assets from the fresh build are already in results/
- # The deploy action with keep_files merges with existing gh-pages content
+ path: suite/results/
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: results
+ publish_dir: suite/results
keep_files: true
on-failure:
@@ -454,7 +446,6 @@ jobs:
- benchmark
- process
- deploy
- - deploy-app
steps:
- name: Slack Notify
uses: rtCamp/action-slack-notify@v2.3.3
@@ -474,4 +465,3 @@ jobs:
Benchmark: ${{ needs.benchmark.result }}
Process: ${{ needs.process.result }}
Deploy: ${{ needs.deploy.result }}
- Deploy App: ${{ needs.deploy-app.result }}
diff --git a/.github/workflows/reprocess.yml b/.github/workflows/reprocess.yml
index 90ab0de278..646e50a12b 100644
--- a/.github/workflows/reprocess.yml
+++ b/.github/workflows/reprocess.yml
@@ -32,14 +32,14 @@ jobs:
- name: Download Results
uses: actions/download-artifact@v8
with:
- path: results
+ path: suite/results
pattern: results-*
run-id: ${{ inputs.run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Download Versions
uses: actions/download-artifact@v8
with:
- path: versions-temp
+ path: suite/versions-temp
pattern: versions-*
run-id: ${{ inputs.run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -47,20 +47,23 @@ jobs:
env:
BENCH_DATE: ${{ inputs.date }}
run: |
- ./bench process
+ ./suite/bench process
+ - name: Checkout gh-pages data
+ uses: actions/checkout@v6
+ with:
+ ref: gh-pages
+ path: gh-pages-data
- name: Install vlt
run: |
npm install -g vlt@latest
- - name: Build Charts View
+ - name: Generate Data Index
run: |
- pushd app
vlt install
- vlt run build
- popd
+ vlt run --workspace=apps/data build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: results
+ publish_dir: suite/results
keep_files: true
full_commit_message: "reprocess: run ${{ inputs.run-id }} -> ${{ inputs.date }} (dispatched via ${{ github.workflow }} run ${{ github.run_id }})"
diff --git a/.gitignore b/.gitignore
index 8aa57f60fc..51f582815e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,11 +13,16 @@ node_modules
dist
chart
charts
-/results/
/versions-temp/
-
-# ignore latest in app for testing
-/app/latest/
+/suite/results/
+/suite/versions-temp/
+/gh-pages-data/
+/tmp/app/latest/
# local agent config (kept untracked, same as vlt.io/vltpkg)
.claude
+
+# agent stuff
+.mcp.json
+.openrig
+CLAUDE.md
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 0000000000..5557c55ba6
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
+ "ignorePatterns": [
+ "**/node_modules/**",
+ "**/dist/**",
+ "**/.next/**",
+ "**/vlt-lock.json",
+ "**/package-lock.json",
+ ".github/**",
+ "bench",
+ "fixtures/**",
+ "scripts/**",
+ "suite/**",
+ "app/**",
+ "tmp/**",
+ "versions-temp/**",
+ "projects/**"
+ ]
+}
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000000..7f83a3df19
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["typescript", "unicorn", "oxc"],
+ "categories": {
+ "correctness": "error"
+ },
+ "rules": {},
+ "env": {
+ "builtin": true
+ },
+ "ignorePatterns": [
+ "**/node_modules/**",
+ "**/dist/**",
+ "**/.next/**",
+ ".github/**",
+ "bench",
+ "fixtures/**",
+ "scripts/**",
+ "suite/**",
+ "app/**",
+ "tmp/**",
+ "versions-temp/**",
+ "projects/**"
+ ]
+}
diff --git a/README.md b/README.md
index 8bdbad9dfd..04d683dbfb 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,42 @@
This repo contains a suite of fixtures & tools to track the performance of package managers. We benchmark various Node.js package managers (npm, yarn, pnpm, berry, deno, bun, vlt, aube, upm, nx, turbo) across different project types and scenarios.
+## Documentation
+
+| Document | For |
+| ------------------------------------------ | -------------------------------------------------------- |
+| [docs/METHODOLOGY.md](docs/METHODOLOGY.md) | what is measured, how, and what the published data means |
+| [docs/FIXTURES/](docs/FIXTURES/README.md) | one page per fixture |
+
+Two things that surprise most newcomers, both documented above: `suite/results/`
+is the **only** results tree — measured into, indexed in place and published from,
+with no copy step anywhere — and `./suite/bench process` can report success while
+taking no new measurements.
+
## Environment
-We currently only test the latest `linux` runner which is the most common GitHub Action environment. The [standard GitHub-hosted public runner environment](https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories) specs are below:
+We currently only test a `linux` runner, the most common GitHub Actions
+environment. The workflow label is read from the workflows themselves — all six
+benchmark jobs in `.github/workflows/benchmark.yaml` and
+`.github/workflows/reprocess.yml` set `runs-on: ubuntu-24.04-arm`:
-- VM: Linux
-- Processor (CPU): 4
+- Workflow label: `ubuntu-24.04-arm`
+- VM: Linux (Ubuntu 24.04)
+- Architecture: `arm64`
+- Processor (CPU): 4 cores
- Memory (RAM): 16 GB
- Storage (SSD): 14 GB
-- Workflow label: `ubuntu-latest`
+
+The label and the architecture come from the workflows. The remaining figures
+are GitHub's [published specification for standard public
+runners](https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories),
+not measurements taken here.
+
+> This section said `ubuntu-latest` until 2026-10-02, with the x64 specs under
+> it. The suite has been running on arm since the move to arm runners, so the
+> published methodology was describing a different machine than the one
+> producing the numbers. If you are comparing these results against your own,
+> that difference matters more than any of the figures above.
We may add Mac/Windows in the future but it will likely exponentially increase the already slow run time of the suite (**~40min**).
@@ -25,12 +52,15 @@ The benchmarks measure:
### Project Types (fixtures)
-- Next.js
-- Astro
-- Svelte
-- Vue
-- Large
-- Babylon.js (Massive 3D engine monorepo with 86+ packages)
+Seven, each with [its own page](docs/FIXTURES/README.md):
+
+- [Next.js](docs/FIXTURES/NEXT.md) — the default fixture; the only one with `build` variations
+- [Astro](docs/FIXTURES/ASTRO.md) — smallest install, one declared dependency
+- [Svelte](docs/FIXTURES/SVELTE.md) — entirely devDependencies
+- [Vue](docs/FIXTURES/VUE.md) — heaviest framework starter
+- [Large](docs/FIXTURES/LARGE.md) — 109 direct dependencies, no application
+- [Babylon.js](docs/FIXTURES/BABYLON.md) — monorepo, 86 workspace packages
+- [Run](docs/FIXTURES/RUN.md) — script execution only, no dependencies
### Package Managers
@@ -87,10 +117,10 @@ Examples:
```bash
# Run only npm + vlt registry benchmarks
-./bench run --variation=registry-clean --fixtures=next --registries=npm,vlt
+./suite/bench run --variation=registry-clean --fixtures=next --registries=npm,vlt
# Run only aws registry benchmarks (requires token)
-CODEARTIFACT_AUTH_TOKEN= ./bench run --variation=registry-clean --fixtures=next --registries=aws
+CODEARTIFACT_AUTH_TOKEN= ./suite/bench run --variation=registry-clean --fixtures=next --registries=aws
```
Auth notes:
@@ -127,61 +157,59 @@ This suite also tests the performance of basic script execution (ex. `npm run fo
```bash
npm install -g npm@latest corepack@latest vlt@latest bun@latest deno@latest nx@latest turbo@latest
```
- - 1.4 Make a new `results` folder:
- ```bash
- mkdir -p results
- ```
+ - 1.4 No directory setup is needed. `./suite/bench` creates its output tree at
+ `suite/results/`, which is the only results tree there is.
- 2. Run benchmarks:
```bash
# Install and benchmark a specific project
- ./bench run --fixtures= --variation=
+ ./suite/bench run --fixtures= --variation=
# Example: Benchmark Next.js with cold cache
- ./bench run --fixtures=next --variation=clean
+ ./suite/bench run --fixtures=next --variation=clean
```
- 3. Local bench wrapper options:
```bash
# Defaults: next + clean (uses script defaults for runs/warmup)
- ./bench run
+ ./suite/bench run
# Pick fixtures, package managers, and runs
- ./bench run --fixtures=next,astro --pms=vlt,pnpm --runs=3
+ ./suite/bench run --fixtures=next,astro --pms=vlt,pnpm --runs=3
# Run all fixtures (except "run")
- ./bench run --fixtures=all --variation=clean
+ ./suite/bench run --fixtures=all --variation=clean
# Run multiple variations in one command
- ./bench run --fixtures=svelte --variation=lockfile,registry-lockfile,run --registries=npm,vlt
+ ./suite/bench run --fixtures=svelte --variation=lockfile,registry-lockfile,run --registries=npm,vlt
# Script-execution benchmark
- ./bench run --variation=run --pms=vlt
+ ./suite/bench run --variation=run --pms=vlt
# Clean failed runs immediately during run/chart (optional)
- ./bench run --fixtures=next --variation=clean --clean
+ ./suite/bench run --fixtures=next --variation=clean --clean
- # Generate chart data (and copy to app/latest) after a run
- ./bench run --fixtures=next --runs=3 --chart
+ # Generate chart data (and copy to tmp/app/latest) after a run
+ ./suite/bench run --fixtures=next --runs=3 --chart
# Run benchmarks then process outputs in one command
- ./bench run --fixtures=next --runs=3 --process
+ ./suite/bench run --fixtures=next --runs=3 --process
# Process existing results into chart data
- ./bench chart --fixtures=next --variation=clean
+ ./suite/bench chart --fixtures=next --variation=clean
```
- By default, cleaning happens during `./bench process`. Use `--clean` on `run`/`chart` for immediate cleanup.
+ By default, cleaning happens during `./suite/bench process`. Use `--clean` on `run`/`chart` for immediate cleanup.
- 4. Process benchmark output:
```bash
# Filter failed runs and generate dated + latest outputs and chart data
- ./bench process
+ ./suite/bench process
# Or pass --process directly when running
- ./bench run --fixtures=next --runs=3 --process
+ ./suite/bench run --fixtures=next --runs=3 --process
```
### GitHub Actions
@@ -256,35 +284,34 @@ Preferred workflow (matches CI):
2. Download artifacts from the latest successful run on `main`:
```sh
- ./scripts/download-latest-artifacts.sh
+ ./suite/scripts/download-latest-artifacts.sh
```
This keeps artifact-style directories such as
- `results/results--/` and `versions-temp/`.
+ `suite/results/results--/` and `suite/versions-temp/`.
3. Process downloaded artifacts:
```sh
- ./bench process
+ ./suite/bench process
```
This cleans benchmark outputs, builds dated and latest result folders,
- and generates chart data (`results//chart-data.json` and `results/latest/chart-data.json`).
+ and generates chart data (`suite/results//chart-data.json` and
+ `suite/results/latest/chart-data.json`).
-4. Copy the latest chart data into the app:
+4. Index and serve the data, then run the current app:
```sh
- mkdir -p app/latest
- cp results/latest/chart-data.json app/latest/chart-data.json
+ node apps/data/bin/build-manifest.mts --out suite/results suite/results
+ node apps/data/bin/serve.mts --root suite/results --port 8811
+ vlt run --workspace=apps/www build && vlt run --workspace=apps/www start
```
-5. Run the app:
-
- ```sh
- cd app
- vlt install
- vlr dev
- ```
+ The current frontend (`apps/www`) fetches from `apps/data` over HTTP, so
+ there is **no chart-data file to copy**. Steps 4 and 5 of the instructions
+ the download script prints are for `tmp/app/`, the parked previous app, and
+ no longer apply.
## License
diff --git a/app/vite.config.ts b/app/vite.config.ts
deleted file mode 100644
index 2bbc3dab60..0000000000
--- a/app/vite.config.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import path from "path";
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-import tailwindcss from "@tailwindcss/vite";
-
-export default defineConfig({
- base: "/",
- plugins: [react(), tailwindcss()],
- resolve: {
- alias: {
- "@": path.resolve(__dirname, "./src"),
- },
- },
- build: {
- outDir: "../results/",
- },
-});
diff --git a/apps/data/bin/build-manifest.mts b/apps/data/bin/build-manifest.mts
new file mode 100755
index 0000000000..8c2ee19844
--- /dev/null
+++ b/apps/data/bin/build-manifest.mts
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/**
+ * Generate index.json and manifest.json for the served tree.
+ *
+ * Usage:
+ * node bin/build-manifest.mts --out [...]
+ *
+ * `--out` is REQUIRED and has no default. A default would point at a fixed
+ * directory, and a default pointing at a directory that may not exist is this
+ * repository's signature failure: it produces a silent, empty success. A bare
+ * invocation must fail loudly instead.
+ *
+ * Sources are scanned in order and later ones win, so CI layers this run's
+ * freshly assembled tree over a checkout of the published history:
+ *
+ * node bin/build-manifest.mts --out suite/results gh-pages-data suite/results
+ *
+ * Writes only index.json, manifest.json and the manifest/ shards. It never
+ * copies or re-emits the published date directories (decision D2), and never
+ * writes an index.html.
+ */
+import { mkdirSync, statSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { buildManifest } from "../src/manifest.ts";
+import { scanRoots } from "../src/scan.ts";
+import { INDEX_FILE, MANIFEST_DIR, MANIFEST_FILE } from "../src/paths.ts";
+
+const argv = process.argv.slice(2);
+let out: string | undefined;
+const roots: string[] = [];
+
+for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "--out") {
+ const value = argv[i + 1];
+ if (value === undefined) {
+ console.error("--out requires a directory");
+ process.exit(2);
+ }
+ out = value;
+ i++;
+ } else if (arg !== undefined && arg.startsWith("--out=")) {
+ out = arg.slice("--out=".length);
+ } else if (arg !== undefined) {
+ roots.push(arg);
+ }
+}
+
+if (out === undefined) {
+ console.error(
+ "--out is required.\n" +
+ "Refusing to guess an output directory: a wrong guess writes an index\n" +
+ "describing nothing and exits 0.\n" +
+ " node bin/build-manifest.mts --out suite/results gh-pages-data suite/results",
+ );
+ process.exit(2);
+}
+
+if (roots.length === 0) roots.push(out);
+
+// Same gap as serve.mts had: a source that is named but absent gets skipped by
+// scanRoots, and if every source is absent the result is an index describing
+// nothing, written successfully, exit 0. A named source must exist.
+const missing = roots.filter((r) => {
+ try {
+ return !statSync(r).isDirectory();
+ } catch {
+ return true;
+ }
+});
+if (missing.length > 0) {
+ console.error(
+ `source directories do not exist: ${missing.join(", ")}\n` +
+ "Refusing to continue: skipping them would write an index describing\n" +
+ "less than the real tree, and exit 0.",
+ );
+ process.exit(2);
+}
+
+const listings = scanRoots(roots);
+const { index, manifest } = buildManifest(listings, new Date().toISOString());
+
+// A tree with no dates at all is never a legitimate outcome here; it means the
+// sources existed but held nothing recognisable.
+if (Object.keys(manifest.entries).length === 0) {
+ console.error(
+ `no date or latest directories found under: ${roots.join(", ")}\n` +
+ "Refusing to write an index that describes nothing.",
+ );
+ process.exit(2);
+}
+
+mkdirSync(out, { recursive: true });
+writeFileSync(join(out, INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`);
+writeFileSync(join(out, MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
+
+// One shard per directory, so a consumer wanting a single date does not pull
+// the whole archive. Each shard is exactly manifest.entries[name] -- no
+// timestamp, no wrapper -- which keeps unchanged dates byte-identical between
+// runs, so the gh-pages deploy records no diff for them.
+const shardDir = join(out, MANIFEST_DIR);
+mkdirSync(shardDir, { recursive: true });
+let shardCount = 0;
+for (const [name, entry] of Object.entries(manifest.entries)) {
+ if (entry === undefined) continue;
+ writeFileSync(join(shardDir, `${name}.json`), `${JSON.stringify(entry, null, 2)}\n`);
+ shardCount++;
+}
+
+const dirCount = Object.keys(manifest.entries).length;
+console.log(
+ `wrote ${INDEX_FILE}, ${MANIFEST_FILE} and ${shardCount} ${MANIFEST_DIR}/ shards to ${out}: ` +
+ `${index.dates.length} dates, ${dirCount} directories, latest=${index.latest ?? "none"}, ` +
+ `${index.fixtures.length} fixtures, ${index.variations.length} variations`,
+);
diff --git a/apps/data/bin/serve.mts b/apps/data/bin/serve.mts
new file mode 100755
index 0000000000..72f438fd53
--- /dev/null
+++ b/apps/data/bin/serve.mts
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+/**
+ * Serve the results tree locally.
+ *
+ * Usage:
+ * node bin/serve.mts --root [--port ]
+ *
+ * `--root` is REQUIRED and has no default. Serving a directory that does not
+ * exist answers 404 to everything while the process reports itself healthy --
+ * the same silent-empty-success this repository keeps producing. Refusing to
+ * guess turns that into an immediate, readable failure.
+ */
+import { statSync } from "node:fs";
+import { createDataServer } from "../src/serve.ts";
+
+const argv = process.argv.slice(2);
+let root: string | undefined;
+let port = 8811;
+
+for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "--root") {
+ root = argv[i + 1] ?? root;
+ i++;
+ } else if (arg === "--port") {
+ port = Number(argv[i + 1] ?? port);
+ i++;
+ } else if (arg === "--watch") {
+ // Accepted so `dev` and `start` can share this entrypoint. The tree is
+ // read per request, so there is nothing to rebuild on change.
+ }
+}
+
+if (root === undefined) {
+ console.error(
+ "--root is required.\n" +
+ "Refusing to guess a directory to serve: a wrong guess answers 404 to\n" +
+ "every request while looking healthy.\n" +
+ " node bin/serve.mts --root suite/results",
+ );
+ process.exit(2);
+}
+
+// Requiring the argument is not enough. suite/results/ is gitignored and
+// absent in a fresh checkout, so the common failure is a root that is supplied
+// and spelled correctly but does not exist yet -- which serves 404 to
+// everything while reporting itself healthy. Same trap, one step later.
+let rootIsDir = false;
+try {
+ rootIsDir = statSync(root).isDirectory();
+} catch {
+ rootIsDir = false;
+}
+if (!rootIsDir) {
+ console.error(
+ `--root ${root} is not a directory.\n` +
+ "Refusing to serve it: an absent root answers 404 to every request while\n" +
+ "the process looks healthy. suite/results/ is gitignored and absent in a\n" +
+ "fresh checkout -- generate it first:\n" +
+ " ./suite/bench process # from measured results\n" +
+ " vlt run --workspace=apps/data build",
+ );
+ process.exit(2);
+}
+
+createDataServer({ root, port }).listen(port, () => {
+ console.log(`serving ${root} on http://localhost:${port}`);
+});
diff --git a/apps/data/package.json b/apps/data/package.json
new file mode 100644
index 0000000000..3fcffbb820
--- /dev/null
+++ b/apps/data/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@benchmarks/data",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Discovery layer over the benchmark data published on gh-pages: a generated index plus a typed client. Does not re-emit the published tree.",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "build": "node bin/build-manifest.mts --out ../../suite/results ../../gh-pages-data ../../suite/results",
+ "dev": "node bin/serve.mts --root ../../suite/results",
+ "start": "node bin/serve.mts --root ../../suite/results",
+ "lint:check": "oxlint bin src test",
+ "lint": "oxlint --fix bin src test",
+ "format:check": "oxfmt --check bin src test",
+ "format": "oxfmt bin src test",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@benchmarks/types": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:hygiene",
+ "oxfmt": "catalog:hygiene",
+ "oxlint": "catalog:hygiene",
+ "typescript": "catalog:hygiene",
+ "vitest": "catalog:testing"
+ }
+}
diff --git a/apps/data/src/client.ts b/apps/data/src/client.ts
new file mode 100644
index 0000000000..1e66629464
--- /dev/null
+++ b/apps/data/src/client.ts
@@ -0,0 +1,140 @@
+/**
+ * Typed client over the served tree.
+ *
+ * This is the only thing `apps/www` should use to reach the data. Per the
+ * mission's interface contract it talks HTTP against a base URL and never
+ * reads across the workspace boundary on disk, so the same code works against
+ * a local server and against the gh-pages origin.
+ */
+import type {
+ BenchmarkFile,
+ ChartData,
+ Fixture,
+ PackageCount,
+ ProcessCount,
+ Variation,
+ Versions,
+} from "@benchmarks/types";
+import type { DataIndex, DataManifest, DirectoryEntry } from "./manifest.ts";
+import {
+ benchmarkFile,
+ CHART_DATA_FILE,
+ dataUrl,
+ INDEX_FILE,
+ MANIFEST_FILE,
+ manifestShardPath,
+ packageCountFile,
+ processCountFile,
+ VERSIONS_FILE,
+} from "./paths.ts";
+
+export type FetchLike = (url: string) => Promise<{
+ ok: boolean;
+ status: number;
+ json: () => Promise;
+}>;
+
+export interface DataClientOptions {
+ /** Base URL of the served tree, e.g. `https://benchmarks.vlt.sh`. */
+ baseUrl: string;
+ /** Injectable for tests; defaults to global fetch. */
+ fetch?: FetchLike;
+}
+
+export class DataFetchError extends Error {
+ readonly url: string;
+ readonly status: number;
+
+ constructor(url: string, status: number) {
+ super(`Request for ${url} failed with status ${status}`);
+ this.name = "DataFetchError";
+ this.url = url;
+ this.status = status;
+ }
+}
+
+export class DataClient {
+ readonly baseUrl: string;
+ readonly #fetch: FetchLike;
+
+ constructor(options: DataClientOptions) {
+ this.baseUrl = options.baseUrl;
+ this.#fetch =
+ options.fetch ?? ((url: string) => fetch(url) as unknown as ReturnType);
+ }
+
+ url(...segments: string[]): string {
+ return dataUrl(this.baseUrl, ...segments);
+ }
+
+ async #get(...segments: string[]): Promise {
+ const url = this.url(...segments);
+ const response = await this.#fetch(url);
+ if (!response.ok) throw new DataFetchError(url, response.status);
+ return (await response.json()) as T;
+ }
+
+ /** The light index: which dates exist, and the observed vocabularies. */
+ getIndex(): Promise {
+ return this.#get(INDEX_FILE);
+ }
+
+ /**
+ * The full per-directory detail: ~842KB over the real tree.
+ *
+ * Prefer {@link getDirectoryEntry} when one directory is wanted. Reach for
+ * this only for genuinely cross-date work.
+ */
+ getManifest(): Promise {
+ return this.#get(MANIFEST_FILE);
+ }
+
+ /**
+ * One directory's entry, without fetching the whole manifest.
+ *
+ * `name` is a `yyyy-mm-dd` date or `latest`. The value is byte-for-byte the
+ * same object as `getManifest().entries[name]`, so switching between the two
+ * changes cost and nothing else.
+ */
+ getDirectoryEntry(name: string): Promise {
+ return this.#get(...manifestShardPath(name));
+ }
+
+ getBenchmark(
+ date: string,
+ fixture: Fixture,
+ variation: Variation | string,
+ ): Promise {
+ return this.#get(date, benchmarkFile(fixture, variation));
+ }
+
+ getPackageCount(
+ date: string,
+ fixture: Fixture,
+ variation: Variation | string,
+ ): Promise {
+ return this.#get(date, packageCountFile(fixture, variation));
+ }
+
+ /**
+ * Note that `registry-*` variations publish no process-count file at all, so
+ * this rejects with a 404 for them rather than returning an empty object.
+ * Check the manifest's `processCounts` before calling.
+ */
+ getProcessCount(
+ date: string,
+ fixture: Fixture,
+ variation: Variation | string,
+ ): Promise {
+ return this.#get(date, processCountFile(fixture, variation));
+ }
+
+ getChartData(date: string): Promise {
+ return this.#get(date, CHART_DATA_FILE);
+ }
+
+ /** `latest/` carries no `versions.json`; this rejects with a 404 there. */
+ getVersions(date: string): Promise {
+ return this.#get(date, VERSIONS_FILE);
+ }
+}
diff --git a/apps/data/src/index.ts b/apps/data/src/index.ts
new file mode 100644
index 0000000000..e0da4af58d
--- /dev/null
+++ b/apps/data/src/index.ts
@@ -0,0 +1,28 @@
+export { DataClient, DataFetchError, type DataClientOptions, type FetchLike } from "./client.ts";
+export {
+ buildDirectoryEntry,
+ buildManifest,
+ INDEX_VERSION,
+ type DataIndex,
+ type DataManifest,
+ type DirectoryEntry,
+ type DirectoryListing,
+ type VariationsByFixture,
+} from "./manifest.ts";
+export {
+ benchmarkFile,
+ CHART_DATA_FILE,
+ dataUrl,
+ INDEX_FILE,
+ LATEST_DIR,
+ MANIFEST_DIR,
+ MANIFEST_FILE,
+ manifestShardPath,
+ packageCountFile,
+ parseDataFile,
+ processCountFile,
+ VERSIONS_FILE,
+ type FileKind,
+ type ParsedDataFile,
+} from "./paths.ts";
+export { scanRoots } from "./scan.ts";
diff --git a/apps/data/src/manifest.ts b/apps/data/src/manifest.ts
new file mode 100644
index 0000000000..83f33451e1
--- /dev/null
+++ b/apps/data/src/manifest.ts
@@ -0,0 +1,164 @@
+/**
+ * The generated index and manifest.
+ *
+ * Both are built from what each directory ACTUALLY contains. That is not
+ * pedantry: `chart-data.json` has six distinct shapes across the 352 published
+ * date directories and five of its seven top-level fields are optional, so an
+ * index derived from an assumed schema would misreport most of the archive.
+ * See packages/types for the survey.
+ */
+import { isDateKey } from "@benchmarks/types";
+import { CHART_DATA_FILE, LATEST_DIR, parseDataFile, VERSIONS_FILE } from "./paths.ts";
+
+export const INDEX_VERSION = 1;
+
+/** Fixture name to the variations present for it, sorted. */
+export type VariationsByFixture = { [fixture: string]: string[] | undefined };
+
+/** What one directory of the published tree contains. */
+export interface DirectoryEntry {
+ /** `yyyy-mm-dd`, or `latest`. */
+ name: string;
+ /** True for `latest`, which is merged rather than replaced and is not a date. */
+ isLatest: boolean;
+ /** Total files seen in the directory, including ones not classified below. */
+ fileCount: number;
+ benchmarks: VariationsByFixture;
+ packageCounts: VariationsByFixture;
+ processCounts: VariationsByFixture;
+ hasChartData: boolean;
+ /** `latest/` carries no `versions.json`, so this is genuinely per-directory. */
+ hasVersions: boolean;
+}
+
+/**
+ * The light index, published at the root of the served tree.
+ *
+ * Deliberately small: navigation only. Pair-level detail lives in the
+ * manifest so a consumer that only needs "which dates exist" does not pay for
+ * the whole archive.
+ */
+export interface DataIndex {
+ version: number;
+ generatedAt: string;
+ /** Newest date directory, or null when the tree has none. */
+ latest: string | null;
+ /** True when a `latest/` directory is present alongside the dates. */
+ hasLatestDir: boolean;
+ /** Date directory names, oldest first. */
+ dates: string[];
+ /** Every fixture observed anywhere in the tree, sorted. */
+ fixtures: string[];
+ /** Every variation observed anywhere in the tree, sorted. */
+ variations: string[];
+}
+
+/** The full detail, published alongside the index. */
+export interface DataManifest {
+ version: number;
+ generatedAt: string;
+ latest: string | null;
+ /** Keyed by directory name, so `latest` sits beside the dates. */
+ entries: { [name: string]: DirectoryEntry | undefined };
+}
+
+/** One directory's raw listing, as a scanner produces it. */
+export interface DirectoryListing {
+ name: string;
+ files: string[];
+}
+
+const addPair = (into: VariationsByFixture, fixture: string, variation: string): void => {
+ const list = into[fixture];
+ if (list === undefined) into[fixture] = [variation];
+ else if (!list.includes(variation)) list.push(variation);
+};
+
+const sortLists = (byFixture: VariationsByFixture): void => {
+ for (const list of Object.values(byFixture)) list?.sort();
+};
+
+/** Classify one directory listing. Pure -- no filesystem, no network. */
+export const buildDirectoryEntry = (listing: DirectoryListing): DirectoryEntry => {
+ const entry: DirectoryEntry = {
+ name: listing.name,
+ isLatest: listing.name === LATEST_DIR,
+ fileCount: listing.files.length,
+ benchmarks: {},
+ packageCounts: {},
+ processCounts: {},
+ hasChartData: listing.files.includes(CHART_DATA_FILE),
+ hasVersions: listing.files.includes(VERSIONS_FILE),
+ };
+
+ for (const file of listing.files) {
+ if (file === CHART_DATA_FILE || file === VERSIONS_FILE) continue;
+ const parsed = parseDataFile(file);
+ if (parsed === undefined) continue;
+ const target =
+ parsed.kind === "benchmark"
+ ? entry.benchmarks
+ : parsed.kind === "package-count"
+ ? entry.packageCounts
+ : entry.processCounts;
+ addPair(target, parsed.fixture, parsed.variation);
+ }
+
+ sortLists(entry.benchmarks);
+ sortLists(entry.packageCounts);
+ sortLists(entry.processCounts);
+ return entry;
+};
+
+/**
+ * Build the index and manifest from directory listings.
+ *
+ * Listings that are neither a `yyyy-mm-dd` date nor `latest` are ignored. That
+ * matters: the publish root also holds `assets/`, `data/`, `old/`, `raw/` and
+ * the raw `results--/` directories every deploy publishes
+ * alongside the processed data (roughly 85 of them).
+ */
+export const buildManifest = (
+ listings: DirectoryListing[],
+ generatedAt: string,
+): { index: DataIndex; manifest: DataManifest } => {
+ const entries: { [name: string]: DirectoryEntry | undefined } = {};
+ const dates: string[] = [];
+ const fixtures = new Set();
+ const variations = new Set();
+ let hasLatestDir = false;
+
+ for (const listing of listings) {
+ const isDate = isDateKey(listing.name);
+ const isLatest = listing.name === LATEST_DIR;
+ if (!isDate && !isLatest) continue;
+
+ const entry = buildDirectoryEntry(listing);
+ entries[listing.name] = entry;
+ if (isDate) dates.push(listing.name);
+ if (isLatest) hasLatestDir = true;
+
+ for (const byFixture of [entry.benchmarks, entry.packageCounts, entry.processCounts]) {
+ for (const [fixture, list] of Object.entries(byFixture)) {
+ fixtures.add(fixture);
+ for (const variation of list ?? []) variations.add(variation);
+ }
+ }
+ }
+
+ dates.sort();
+ const latest = dates.at(-1) ?? null;
+
+ return {
+ index: {
+ version: INDEX_VERSION,
+ generatedAt,
+ latest,
+ hasLatestDir,
+ dates,
+ fixtures: [...fixtures].sort(),
+ variations: [...variations].sort(),
+ },
+ manifest: { version: INDEX_VERSION, generatedAt, latest, entries },
+ };
+};
diff --git a/apps/data/src/paths.ts b/apps/data/src/paths.ts
new file mode 100644
index 0000000000..8a04263253
--- /dev/null
+++ b/apps/data/src/paths.ts
@@ -0,0 +1,113 @@
+/**
+ * Filename conventions of the published tree.
+ *
+ * The tree is frozen (HARD CONSTRAINT 3), so these describe what is already
+ * there. Nothing here invents a path.
+ */
+import { type Fixture, FIXTURES, type Variation } from "@benchmarks/types";
+
+export const CHART_DATA_FILE = "chart-data.json";
+export const VERSIONS_FILE = "versions.json";
+export const LATEST_DIR = "latest";
+
+/** Names this package publishes at the root of the served tree. */
+export const INDEX_FILE = "index.json";
+export const MANIFEST_FILE = "manifest.json";
+
+/**
+ * Directory holding one shard per published directory.
+ *
+ * `manifest.json` answers cross-date questions and `index.json` answers "which
+ * dates exist", but neither answers "what is in THIS date" without pulling the
+ * whole archive. The shards close that hole. Measured over the real tree:
+ * index + one shard is 12,093 bytes against 849,397 for index + manifest.
+ */
+export const MANIFEST_DIR = "manifest";
+
+/**
+ * Path of one directory's shard, relative to the tree root.
+ *
+ * `name` is a `yyyy-mm-dd` date or `latest`. Deliberately a sibling directory
+ * rather than a file inside each date directory: decision D2 forbids
+ * re-emitting the published date directories, and this keeps every new file in
+ * one new subtree.
+ */
+export const manifestShardPath = (name: string): string[] => [MANIFEST_DIR, `${name}.json`];
+
+const PACKAGE_COUNT_SUFFIX = "-package-count.json";
+const PROCESS_COUNT_SUFFIX = "-process-count.json";
+const JSON_SUFFIX = ".json";
+
+/** What a filename in a date directory refers to. */
+export type FileKind = "benchmark" | "package-count" | "process-count";
+
+export interface ParsedDataFile {
+ kind: FileKind;
+ fixture: Fixture;
+ /**
+ * The variation, as a plain string rather than {@link Variation}.
+ *
+ * The published tree can hold variations this checkout's suite does not.
+ * `ci+setup` was added upstream on 2026-09-30 and published the same day,
+ * while this workspace was on an earlier base. A discovery layer that
+ * validated against the union would under-report what is actually there.
+ */
+ variation: string;
+}
+
+const FIXTURE_SET: ReadonlySet = new Set(FIXTURES);
+
+/**
+ * Classify a filename from a date directory, or return undefined if it is not
+ * one of the three per-fixture kinds.
+ *
+ * Splitting on the first `-` is safe because no fixture name contains one,
+ * while several variations do (`build-cache`, `registry-clean`).
+ */
+export const parseDataFile = (name: string): ParsedDataFile | undefined => {
+ let kind: FileKind;
+ let stem: string;
+
+ if (name.endsWith(PACKAGE_COUNT_SUFFIX)) {
+ kind = "package-count";
+ stem = name.slice(0, -PACKAGE_COUNT_SUFFIX.length);
+ } else if (name.endsWith(PROCESS_COUNT_SUFFIX)) {
+ kind = "process-count";
+ stem = name.slice(0, -PROCESS_COUNT_SUFFIX.length);
+ } else if (name.endsWith(JSON_SUFFIX)) {
+ kind = "benchmark";
+ stem = name.slice(0, -JSON_SUFFIX.length);
+ } else {
+ return undefined;
+ }
+
+ const split = stem.indexOf("-");
+ if (split <= 0) return undefined;
+
+ const fixture = stem.slice(0, split);
+ const variation = stem.slice(split + 1);
+ if (!FIXTURE_SET.has(fixture) || variation.length === 0) return undefined;
+
+ return { kind, fixture: fixture as Fixture, variation };
+};
+
+export const benchmarkFile = (fixture: Fixture, variation: Variation | string): string =>
+ `${fixture}-${variation}.json`;
+
+export const packageCountFile = (fixture: Fixture, variation: Variation | string): string =>
+ `${fixture}-${variation}${PACKAGE_COUNT_SUFFIX}`;
+
+export const processCountFile = (fixture: Fixture, variation: Variation | string): string =>
+ `${fixture}-${variation}${PROCESS_COUNT_SUFFIX}`;
+
+/**
+ * Join path segments into a URL relative to a base, encoding each segment.
+ *
+ * Several variations contain `+`. It is legal literally in a path segment, but
+ * it is encoded here so the URL is unambiguous for any consumer that treats
+ * the value as form-encoded.
+ */
+export const dataUrl = (baseUrl: string, ...segments: string[]): string => {
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
+ return base + segments.map((s) => encodeURIComponent(s)).join("/");
+};
diff --git a/apps/data/src/scan.ts b/apps/data/src/scan.ts
new file mode 100644
index 0000000000..24af88cd57
--- /dev/null
+++ b/apps/data/src/scan.ts
@@ -0,0 +1,39 @@
+/** Read directory listings off disk, for the manifest builder to classify. */
+import { readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+import type { DirectoryListing } from "./manifest.ts";
+
+/**
+ * List the immediate subdirectories of each root and the files in them.
+ *
+ * Later roots win on name collisions, so a caller can layer this run's freshly
+ * assembled output over a checkout of the published history.
+ */
+export const scanRoots = (roots: string[]): DirectoryListing[] => {
+ const byName = new Map();
+
+ for (const root of roots) {
+ let names: string[];
+ try {
+ names = readdirSync(root);
+ } catch {
+ // A missing root is not an error: the reprocess workflow runs without a
+ // freshly assembled tree, and local dev often has only one of the two.
+ continue;
+ }
+
+ for (const name of names.sort()) {
+ const dir = join(root, name);
+ let files: string[];
+ try {
+ if (!statSync(dir).isDirectory()) continue;
+ files = readdirSync(dir).sort();
+ } catch {
+ continue;
+ }
+ byName.set(name, { name, files });
+ }
+ }
+
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
+};
diff --git a/apps/data/src/serve.ts b/apps/data/src/serve.ts
new file mode 100644
index 0000000000..ccc0b69889
--- /dev/null
+++ b/apps/data/src/serve.ts
@@ -0,0 +1,73 @@
+/**
+ * Minimal static server for the assembled tree.
+ *
+ * Deliberately dependency-free (`node:http` only) and deliberately does not
+ * serve a directory index. The published root's `index.html` IS the live site
+ * at benchmarks.vlt.sh, and this app must never be the thing that puts an
+ * index page there.
+ */
+import { createServer, type Server } from "node:http";
+import { createReadStream, statSync } from "node:fs";
+import { join, normalize, resolve, sep } from "node:path";
+
+const CONTENT_TYPES: { [extension: string]: string | undefined } = {
+ ".json": "application/json; charset=utf-8",
+ ".txt": "text/plain; charset=utf-8",
+ ".log": "text/plain; charset=utf-8",
+};
+
+export interface ServeOptions {
+ root: string;
+ port: number;
+ host?: string;
+}
+
+/** Resolve a request path to a file inside root, or undefined if it escapes. */
+export const resolveRequestPath = (root: string, urlPath: string): string | undefined => {
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(urlPath);
+ } catch {
+ return undefined;
+ }
+ if (decoded.includes("\0")) return undefined;
+
+ const rootAbs = resolve(root);
+ const candidate = resolve(join(rootAbs, normalize(decoded)));
+ if (candidate !== rootAbs && !candidate.startsWith(rootAbs + sep)) return undefined;
+ return candidate;
+};
+
+export const createDataServer = (options: ServeOptions): Server =>
+ createServer((request, response) => {
+ const urlPath = new URL(request.url ?? "/", "http://localhost").pathname;
+ const filePath = resolveRequestPath(options.root, urlPath);
+
+ if (filePath === undefined) {
+ response.writeHead(400, { "content-type": "text/plain" });
+ response.end("bad request\n");
+ return;
+ }
+
+ let isFile = false;
+ try {
+ isFile = statSync(filePath).isFile();
+ } catch {
+ isFile = false;
+ }
+
+ if (!isFile) {
+ // No directory listing and no index fallback, on purpose.
+ response.writeHead(404, { "content-type": "text/plain" });
+ response.end("not found\n");
+ return;
+ }
+
+ const dot = filePath.lastIndexOf(".");
+ const extension = dot === -1 ? "" : filePath.slice(dot);
+ response.writeHead(200, {
+ "content-type": CONTENT_TYPES[extension] ?? "application/octet-stream",
+ "access-control-allow-origin": "*",
+ });
+ createReadStream(filePath).pipe(response);
+ });
diff --git a/apps/data/test/client.test.ts b/apps/data/test/client.test.ts
new file mode 100644
index 0000000000..a0d1d75a40
--- /dev/null
+++ b/apps/data/test/client.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it, vi } from "vitest";
+import { DataClient, DataFetchError, type FetchLike } from "../src/client.ts";
+
+const stub = (body: unknown, ok = true, status = 200): { fetch: FetchLike; urls: string[] } => {
+ const urls: string[] = [];
+ const fetch: FetchLike = (url) => {
+ urls.push(url);
+ return Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
+ };
+ return { fetch, urls };
+};
+
+describe("DataClient url construction", () => {
+ it("requests the index and manifest from the tree root", async () => {
+ const { fetch, urls } = stub({});
+ const client = new DataClient({ baseUrl: "https://x.test", fetch });
+ await client.getIndex();
+ await client.getManifest();
+ expect(urls).toEqual(["https://x.test/index.json", "https://x.test/manifest.json"]);
+ });
+
+ it("builds the three per-fixture paths correctly", async () => {
+ const { fetch, urls } = stub({});
+ const client = new DataClient({ baseUrl: "https://x.test", fetch });
+ await client.getBenchmark("2026-09-30", "next", "clean");
+ await client.getPackageCount("2026-09-30", "astro", "registry-clean");
+ await client.getProcessCount("2026-09-30", "vue", "cache");
+ expect(urls).toEqual([
+ "https://x.test/2026-09-30/next-clean.json",
+ "https://x.test/2026-09-30/astro-registry-clean-package-count.json",
+ "https://x.test/2026-09-30/vue-cache-process-count.json",
+ ]);
+ });
+
+ it("encodes + in variation names", async () => {
+ const { fetch, urls } = stub({});
+ const client = new DataClient({ baseUrl: "https://x.test", fetch });
+ await client.getBenchmark("2026-09-30", "astro", "cache+lockfile+node_modules");
+ expect(urls[0]).toBe("https://x.test/2026-09-30/astro-cache%2Blockfile%2Bnode_modules.json");
+ });
+
+ it("fetches one directory entry from its shard, not the whole manifest", async () => {
+ const { fetch, urls } = stub({});
+ const client = new DataClient({ baseUrl: "https://x.test", fetch });
+ await client.getDirectoryEntry("2026-09-30");
+ await client.getDirectoryEntry("latest");
+ expect(urls).toEqual([
+ "https://x.test/manifest/2026-09-30.json",
+ "https://x.test/manifest/latest.json",
+ ]);
+ // The point of the method: it must not reach for manifest.json.
+ expect(urls.some((u) => u.endsWith("/manifest.json"))).toBe(false);
+ });
+
+ it("reaches chart-data and versions per date", async () => {
+ const { fetch, urls } = stub({});
+ const client = new DataClient({ baseUrl: "https://x.test/", fetch });
+ await client.getChartData("2026-09-30");
+ await client.getVersions("2026-09-30");
+ expect(urls).toEqual([
+ "https://x.test/2026-09-30/chart-data.json",
+ "https://x.test/2026-09-30/versions.json",
+ ]);
+ });
+});
+
+describe("DataClient errors", () => {
+ it("throws DataFetchError carrying the url and status", async () => {
+ const { fetch } = stub(undefined, false, 404);
+ const client = new DataClient({ baseUrl: "https://x.test", fetch });
+ await expect(client.getVersions("latest")).rejects.toBeInstanceOf(DataFetchError);
+ await expect(client.getVersions("latest")).rejects.toMatchObject({
+ status: 404,
+ url: "https://x.test/latest/versions.json",
+ });
+ });
+
+ it("does not swallow a 404 into an empty object", async () => {
+ // registry-* variations have no process-count file. Callers must be able
+ // to tell "absent" from "present but empty".
+ const { fetch } = stub({}, false, 404);
+ const client = new DataClient({ baseUrl: "https://x.test", fetch });
+ const promise = client.getProcessCount("2026-09-30", "astro", "registry-clean");
+ await expect(promise).rejects.toThrow(/failed with status 404/);
+ });
+
+ it("uses global fetch when none is injected", () => {
+ const spy = vi.fn();
+ const client = new DataClient({ baseUrl: "https://x.test" });
+ expect(client.url("index.json")).toBe("https://x.test/index.json");
+ expect(spy).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/data/test/manifest.test.ts b/apps/data/test/manifest.test.ts
new file mode 100644
index 0000000000..a49bbe0b19
--- /dev/null
+++ b/apps/data/test/manifest.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest";
+import { buildDirectoryEntry, buildManifest, INDEX_VERSION } from "../src/manifest.ts";
+
+const AT = "2026-09-30T00:00:00.000Z";
+
+describe("buildDirectoryEntry", () => {
+ it("groups variations by fixture per kind", () => {
+ const entry = buildDirectoryEntry({
+ name: "2026-09-30",
+ files: [
+ "astro-clean.json",
+ "astro-clean-package-count.json",
+ "astro-clean-process-count.json",
+ "astro-cache+lockfile.json",
+ "next-clean.json",
+ "chart-data.json",
+ "versions.json",
+ ],
+ });
+ expect(entry.benchmarks).toEqual({
+ astro: ["cache+lockfile", "clean"],
+ next: ["clean"],
+ });
+ expect(entry.packageCounts).toEqual({ astro: ["clean"] });
+ expect(entry.processCounts).toEqual({ astro: ["clean"] });
+ expect(entry.hasChartData).toBe(true);
+ expect(entry.hasVersions).toBe(true);
+ expect(entry.isLatest).toBe(false);
+ expect(entry.fileCount).toBe(7);
+ });
+
+ it("records a registry variation as having no process-count", () => {
+ // The published tree has 0 registry-* process-count files. The manifest
+ // must reflect that rather than implying the triple always exists.
+ const entry = buildDirectoryEntry({
+ name: "2026-09-30",
+ files: ["astro-registry-clean.json", "astro-registry-clean-package-count.json"],
+ });
+ expect(entry.benchmarks).toEqual({ astro: ["registry-clean"] });
+ expect(entry.packageCounts).toEqual({ astro: ["registry-clean"] });
+ expect(entry.processCounts).toEqual({});
+ });
+
+ it("marks latest/ and reports its missing versions.json", () => {
+ const entry = buildDirectoryEntry({
+ name: "latest",
+ files: ["next-clean.json", "chart-data.json"],
+ });
+ expect(entry.isLatest).toBe(true);
+ expect(entry.hasVersions).toBe(false);
+ expect(entry.hasChartData).toBe(true);
+ });
+
+ it("counts unclassifiable files but does not index them", () => {
+ const entry = buildDirectoryEntry({
+ name: "2026-09-30",
+ files: ["next-clean.json", "npm-output-0.log", "README.md"],
+ });
+ expect(entry.fileCount).toBe(3);
+ expect(entry.benchmarks).toEqual({ next: ["clean"] });
+ });
+});
+
+describe("buildManifest", () => {
+ it("ignores everything at the publish root that is not a date or latest", () => {
+ const { index, manifest } = buildManifest(
+ [
+ { name: "2025-08-22", files: ["next-clean.json"] },
+ { name: "2026-09-30", files: ["astro-clean.json", "chart-data.json"] },
+ { name: "latest", files: ["astro-clean.json"] },
+ // All of these really are at the gh-pages root.
+ { name: "assets", files: ["index-abc.js"] },
+ { name: "data", files: ["x.json"] },
+ { name: "old", files: [] },
+ { name: "raw", files: [] },
+ { name: "results-astro-clean", files: ["benchmarks.json"] },
+ { name: "results-astro-ci+setup", files: ["benchmarks.json"] },
+ ],
+ AT,
+ );
+ expect(index.dates).toEqual(["2025-08-22", "2026-09-30"]);
+ expect(index.hasLatestDir).toBe(true);
+ expect(Object.keys(manifest.entries).sort()).toEqual(["2025-08-22", "2026-09-30", "latest"]);
+ });
+
+ it("reports latest as the newest DATE, never the latest/ directory", () => {
+ const { index } = buildManifest(
+ [
+ { name: "2026-01-02", files: [] },
+ { name: "latest", files: [] },
+ { name: "2025-12-31", files: [] },
+ ],
+ AT,
+ );
+ expect(index.latest).toBe("2026-01-02");
+ expect(index.dates).toEqual(["2025-12-31", "2026-01-02"]);
+ });
+
+ it("collects the observed vocabularies rather than assuming them", () => {
+ const { index } = buildManifest(
+ [{ name: "2026-09-30", files: ["astro-ci+setup.json", "run-run.json"] }],
+ AT,
+ );
+ expect(index.fixtures).toEqual(["astro", "run"]);
+ expect(index.variations).toEqual(["ci+setup", "run"]);
+ });
+
+ it("handles an empty tree without inventing a latest", () => {
+ const { index, manifest } = buildManifest([], AT);
+ expect(index.latest).toBeNull();
+ expect(index.dates).toEqual([]);
+ expect(index.hasLatestDir).toBe(false);
+ expect(manifest.entries).toEqual({});
+ expect(index.version).toBe(INDEX_VERSION);
+ });
+});
diff --git a/apps/data/test/paths.test.ts b/apps/data/test/paths.test.ts
new file mode 100644
index 0000000000..e16a334f91
--- /dev/null
+++ b/apps/data/test/paths.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest";
+import { dataUrl, manifestShardPath, parseDataFile } from "../src/paths.ts";
+
+describe("parseDataFile", () => {
+ it("splits a plain variation", () => {
+ expect(parseDataFile("next-clean.json")).toEqual({
+ kind: "benchmark",
+ fixture: "next",
+ variation: "clean",
+ });
+ });
+
+ it("keeps hyphens that belong to the variation", () => {
+ // The reason splitting on the FIRST hyphen is correct: no fixture name
+ // contains one, but several variations do.
+ expect(parseDataFile("astro-registry-clean.json")).toEqual({
+ kind: "benchmark",
+ fixture: "astro",
+ variation: "registry-clean",
+ });
+ expect(parseDataFile("next-build-cache.json")).toEqual({
+ kind: "benchmark",
+ fixture: "next",
+ variation: "build-cache",
+ });
+ });
+
+ it("handles variations containing +", () => {
+ expect(parseDataFile("astro-cache+lockfile+node_modules.json")).toEqual({
+ kind: "benchmark",
+ fixture: "astro",
+ variation: "cache+lockfile+node_modules",
+ });
+ });
+
+ it("distinguishes the three kinds, including on a registry variation", () => {
+ expect(parseDataFile("astro-registry-clean-package-count.json")).toEqual({
+ kind: "package-count",
+ fixture: "astro",
+ variation: "registry-clean",
+ });
+ expect(parseDataFile("vue-cache+lockfile-process-count.json")).toEqual({
+ kind: "process-count",
+ fixture: "vue",
+ variation: "cache+lockfile",
+ });
+ });
+
+ it("accepts a variation newer than this checkout's suite", () => {
+ // `ci+setup` was added upstream on 2026-09-30 and published the same day,
+ // ahead of this workspace's base at the time. Rejecting such a variation
+ // would under-report the tree.
+ expect(parseDataFile("astro-ci+setup.json")).toEqual({
+ kind: "benchmark",
+ fixture: "astro",
+ variation: "ci+setup",
+ });
+ });
+
+ it("rejects everything that is not a per-fixture data file", () => {
+ for (const name of [
+ "chart-data.json",
+ "versions.json",
+ "index.json",
+ "manifest.json",
+ ".nojekyll",
+ "CNAME",
+ "index.html",
+ "astro.json",
+ "-clean.json",
+ "unknownfixture-clean.json",
+ "astro-.json",
+ "astro-clean.txt",
+ ]) {
+ expect(parseDataFile(name), name).toBeUndefined();
+ }
+ });
+});
+
+describe("dataUrl", () => {
+ it("joins segments onto a base with or without a trailing slash", () => {
+ expect(dataUrl("https://x.test", "2026-09-30", "next-clean.json")).toBe(
+ "https://x.test/2026-09-30/next-clean.json",
+ );
+ expect(dataUrl("https://x.test/", "index.json")).toBe("https://x.test/index.json");
+ });
+
+ it("encodes + in a variation so the URL is unambiguous", () => {
+ expect(dataUrl("https://x.test", "2026-09-30", "astro-cache+lockfile.json")).toBe(
+ "https://x.test/2026-09-30/astro-cache%2Blockfile.json",
+ );
+ });
+});
+
+describe("manifestShardPath", () => {
+ it("puts shards in a sibling directory, not inside the date directory", () => {
+ // Decision D2 forbids re-emitting the published date directories, so the
+ // shard must not land inside one.
+ expect(manifestShardPath("2026-09-30")).toEqual(["manifest", "2026-09-30.json"]);
+ expect(manifestShardPath("2026-09-30")[0]).not.toBe("2026-09-30");
+ });
+
+ it("shards latest/ alongside the dates", () => {
+ expect(manifestShardPath("latest")).toEqual(["manifest", "latest.json"]);
+ });
+
+ it("builds a fetchable url", () => {
+ expect(dataUrl("https://x.test", ...manifestShardPath("2026-09-30"))).toBe(
+ "https://x.test/manifest/2026-09-30.json",
+ );
+ });
+});
diff --git a/apps/data/test/served-tree.test.ts b/apps/data/test/served-tree.test.ts
new file mode 100644
index 0000000000..df633fe73c
--- /dev/null
+++ b/apps/data/test/served-tree.test.ts
@@ -0,0 +1,246 @@
+/**
+ * End-to-end over REAL published data: materialize a subset of gh-pages, build
+ * the manifest, serve it, and fetch through the client.
+ *
+ * Reads the branch through `git show` -- no network. Skips when the ref is
+ * absent so a shallow clone does not fail; the unit tests always run.
+ */
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { AddressInfo } from "node:net";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { DataClient, DataFetchError } from "../src/client.ts";
+import type { DataIndex, DataManifest } from "../src/manifest.ts";
+import { createDataServer } from "../src/serve.ts";
+
+const REF = "origin/gh-pages";
+
+/**
+ * Every git call runs from the repository root. `git ls-tree` resolves paths
+ * against the current prefix, and vitest runs from the package directory -- a
+ * trap that silently returned an empty listing at exit 0 in packages/types.
+ */
+const REPO_ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], {
+ encoding: "utf8",
+}).trim();
+
+const git = (...args: string[]): string =>
+ execFileSync("git", args, {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ maxBuffer: 64 * 1024 * 1024,
+ });
+
+const refExists = (): boolean => {
+ try {
+ git("rev-parse", "--verify", `${REF}^{commit}`);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+// One current date, the oldest date, and latest/ -- the three directories with
+// materially different contents.
+const DIRS = ["2026-09-30", "2025-08-22", "latest"];
+
+let root = "";
+let baseUrl = "";
+let server: ReturnType | undefined;
+let index: DataIndex;
+let manifest: DataManifest;
+
+const skip = !refExists();
+
+describe.skipIf(skip)("served tree, built from real gh-pages data", () => {
+ beforeAll(async () => {
+ root = mkdtempSync(join(tmpdir(), "bench-data-"));
+ for (const dir of DIRS) {
+ mkdirSync(join(root, dir), { recursive: true });
+ for (const name of git("ls-tree", "--name-only", `${REF}:${dir}`)
+ .split("\n")
+ .filter(Boolean)) {
+ writeFileSync(join(root, dir, name), git("show", `${REF}:${dir}/${name}`));
+ }
+ }
+ // Also reproduce the junk the publish root really carries, to prove the
+ // scanner ignores it.
+ mkdirSync(join(root, "assets"), { recursive: true });
+ writeFileSync(join(root, "assets", "index-abc.js"), "// ui asset\n");
+ mkdirSync(join(root, "results-astro-clean"), { recursive: true });
+ writeFileSync(join(root, "results-astro-clean", "benchmarks.json"), "{}\n");
+ writeFileSync(join(root, "index.html"), "live site\n");
+ writeFileSync(join(root, "CNAME"), "benchmarks.vlt.sh\n");
+
+ // Run the REAL CLI rather than reimplementing what it does. An earlier
+ // version of this fixture built the index and shards inline, which meant a
+ // change to the CLI could not fail any test -- found by injecting a
+ // timestamp into the CLI's shards and watching the suite still pass.
+ execFileSync(
+ process.execPath,
+ [join(import.meta.dirname, "..", "bin", "build-manifest.mts"), "--out", root, root],
+ { encoding: "utf8" },
+ );
+ index = JSON.parse(readFileSync(join(root, "index.json"), "utf8")) as DataIndex;
+ manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as DataManifest;
+
+ server = createDataServer({ root, port: 0 });
+ await new Promise((done) => server?.listen(0, "127.0.0.1", done));
+ const address = server?.address() as AddressInfo;
+ baseUrl = `http://127.0.0.1:${address.port}`;
+ });
+
+ afterAll(() => {
+ server?.close();
+ if (root) rmSync(root, { recursive: true, force: true });
+ });
+
+ const client = (): DataClient => new DataClient({ baseUrl });
+
+ it("indexes the real dates and ignores the publish-root junk", async () => {
+ const served = await client().getIndex();
+ expect(served.dates).toEqual(["2025-08-22", "2026-09-30"]);
+ expect(served.latest).toBe("2026-09-30");
+ expect(served.hasLatestDir).toBe(true);
+ expect(Object.keys(served).sort()).toEqual([
+ "dates",
+ "fixtures",
+ "generatedAt",
+ "hasLatestDir",
+ "latest",
+ "variations",
+ "version",
+ ]);
+ const entries = (await client().getManifest()).entries;
+ expect(Object.keys(entries).sort()).toEqual(["2025-08-22", "2026-09-30", "latest"]);
+ });
+
+ it("serves a shard equal to the manifest entry, for every published directory", async () => {
+ // The invariant that makes the two interchangeable: a consumer switching
+ // from getManifest() to getDirectoryEntry() changes cost and nothing else.
+ const served = await client().getManifest();
+ for (const name of Object.keys(served.entries)) {
+ const shard = await client().getDirectoryEntry(name);
+ expect(shard, name).toEqual(served.entries[name]);
+ }
+ // Includes the oldest date, the newest, one with a '+' variation, and latest/.
+ expect(Object.keys(served.entries).sort()).toEqual(["2025-08-22", "2026-09-30", "latest"]);
+ const newest = await client().getDirectoryEntry("2026-09-30");
+ expect(newest.benchmarks.astro).toContain("cache+lockfile+node_modules");
+ const oldest = await client().getDirectoryEntry("2025-08-22");
+ expect(oldest.hasChartData).toBe(true);
+ const latest = await client().getDirectoryEntry("latest");
+ expect(latest.isLatest).toBe(true);
+ expect(latest.hasVersions).toBe(false);
+ });
+
+ it("carries no timestamp in a shard, so unchanged dates stay byte-identical", () => {
+ // If a shard embedded generatedAt, all 353 would change every nightly run
+ // and the gh-pages branch would grow by 353 blobs a day.
+ for (const entry of Object.values(manifest.entries)) {
+ const keys = Object.keys(entry ?? {});
+ expect(keys).not.toContain("generatedAt");
+ expect(keys.some((k) => /at$|time/i.test(k))).toBe(false);
+ }
+ });
+
+ it("a shard costs roughly one entry, not the whole archive", async () => {
+ // Scale-independent: a shard must be about the size of its own entry, so
+ // the saving grows with the number of published directories. This fixture
+ // has three, so no fixed ratio is assertable here -- the real-tree figure
+ // (842,509 bytes against a 5,205-byte shard, 161.9:1) is in the evidence.
+ const shardText = JSON.stringify(await client().getDirectoryEntry("2026-09-30"));
+ const fullText = JSON.stringify(await client().getManifest());
+ const entryCount = Object.keys(manifest.entries).length;
+ expect(shardText.length).toBeLessThan(fullText.length);
+ // Within a small constant of the manifest's per-entry share.
+ expect(shardText.length).toBeLessThan((fullText.length / entryCount) * 3);
+ });
+
+ it("does not publish an index.html of its own", () => {
+ // The gh-pages root index.html IS the live site. This app writes only the
+ // two JSON files; the one in the fixture is the pre-existing site file.
+ const written = readFileSync(join(root, "index.html"), "utf8");
+ expect(written).toContain("live site");
+ expect(index).not.toHaveProperty("html");
+ });
+
+ it("serves a + variation through the client, end to end", async () => {
+ const file = await client().getBenchmark("2026-09-30", "astro", "cache+lockfile+node_modules");
+ expect(file.results.length).toBeGreaterThan(0);
+ const first = file.results[0];
+ expect(typeof first?.command).toBe("string");
+ expect(typeof first?.median).toBe("number");
+ expect(first?.exit_codes).toHaveLength(first?.times.length ?? -1);
+ });
+
+ it("serves a registry-* benchmark and its REGISTRY-keyed package-count", async () => {
+ const file = await client().getBenchmark("2026-09-30", "astro", "registry-clean");
+ expect(file.results.length).toBeGreaterThan(0);
+ const counts = await client().getPackageCount("2026-09-30", "astro", "registry-clean");
+ expect(Object.keys(counts).sort()).toEqual([
+ "aws",
+ "cloudsmith",
+ "github",
+ "jfrog",
+ "npm",
+ "vlt",
+ ]);
+ });
+
+ it("reports registry-* variations as having no process-count, and 404s for one", async () => {
+ const entry = manifest.entries["2026-09-30"];
+ expect(entry?.benchmarks.astro).toContain("registry-clean");
+ expect(entry?.processCounts.astro ?? []).not.toContain("registry-clean");
+ await expect(
+ client().getProcessCount("2026-09-30", "astro", "registry-clean"),
+ ).rejects.toBeInstanceOf(DataFetchError);
+ });
+
+ it("distinguishes package-counts with minCount/maxCount from those without", async () => {
+ const withRange = await client().getPackageCount("2026-09-30", "astro", "cache+lockfile");
+ const withoutRange = await client().getPackageCount("2026-09-30", "astro", "clean");
+ expect(Object.values(withRange).some((e) => e?.minCount !== undefined)).toBe(true);
+ expect(Object.values(withoutRange).every((e) => e?.minCount === undefined)).toBe(true);
+ });
+
+ it("records that latest/ has no versions.json, and 404s for it", async () => {
+ expect(manifest.entries["latest"]?.hasVersions).toBe(false);
+ expect(manifest.entries["2026-09-30"]?.hasVersions).toBe(true);
+ await expect(client().getVersions("latest")).rejects.toMatchObject({ status: 404 });
+ const versions = await client().getVersions("2026-09-30");
+ expect(Object.keys(versions).length).toBeGreaterThan(0);
+ });
+
+ it("serves the newest chart-data with all seven keys", async () => {
+ const chart = await client().getChartData("2026-09-30");
+ expect(Object.keys(chart).sort()).toEqual([
+ "chartData",
+ "commitSha",
+ "date",
+ "perPackageCountChartData",
+ "registryChartData",
+ "registryPerPackageCountChartData",
+ "versions",
+ ]);
+ });
+
+ it("serves the OLDEST chart-data, which has only its two required keys", async () => {
+ const chart = await client().getChartData("2025-08-22");
+ expect(Object.keys(chart).sort()).toEqual(["chartData", "perPackageCountChartData"]);
+ expect(chart.date).toBeUndefined();
+ expect(chart.versions).toBeUndefined();
+ expect(chart.registryChartData).toBeUndefined();
+ });
+
+ it("serves JSON with a json content-type and 404s outside the tree", async () => {
+ const response = await fetch(`${baseUrl}/index.json`);
+ expect(response.headers.get("content-type")).toContain("application/json");
+ expect((await fetch(`${baseUrl}/nope.json`)).status).toBe(404);
+ // No directory listing, and no traversal.
+ expect((await fetch(`${baseUrl}/2026-09-30/`)).status).toBe(404);
+ expect((await fetch(`${baseUrl}/../../etc/passwd`)).status).toBeGreaterThanOrEqual(400);
+ });
+});
diff --git a/apps/data/tsconfig.json b/apps/data/tsconfig.json
new file mode 100644
index 0000000000..7b24c2fcc7
--- /dev/null
+++ b/apps/data/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ // Node runs this package's TypeScript directly (native type stripping, no
+ // bundler -- decision D2 forbids re-emitting the published tree). Stripping
+ // only erases types, so syntax that needs codegen must be rejected here
+ // rather than at runtime.
+ "erasableSyntaxOnly": true,
+ "allowImportingTsExtensions": true,
+ "types": ["node"]
+ },
+ "include": ["bin", "src", "test"]
+}
diff --git a/apps/data/vitest.config.mts b/apps/data/vitest.config.mts
new file mode 100644
index 0000000000..8b5840acac
--- /dev/null
+++ b/apps/data/vitest.config.mts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["test/**/*.test.ts"],
+ },
+});
diff --git a/apps/www/.env.example b/apps/www/.env.example
new file mode 100644
index 0000000000..6f3143c2ff
--- /dev/null
+++ b/apps/www/.env.example
@@ -0,0 +1,21 @@
+# Base URL of the served benchmark data tree (@benchmarks/data).
+#
+# NEXT_PUBLIC_DATA_URL is read at BUILD time. Next constant-folds every
+# NEXT_PUBLIC_* read into the bundle, so changing this value requires a
+# rebuild -- `next start` will not pick it up.
+#
+# Local development, after `vlt run --workspace=apps/data build`:
+# vlt run --workspace=apps/data start # serves results/ on :8811
+#
+# Production is the gh-pages origin, which serves the same tree:
+# NEXT_PUBLIC_DATA_URL=https://benchmarks.vlt.sh
+NEXT_PUBLIC_DATA_URL=http://localhost:8811
+
+# Optional server-side runtime override, checked first when set.
+#
+# Every fetch in this app happens on the server, so this one is NOT inlined and
+# does repoint an already-built app at another origin. Useful for pointing a
+# single build at a staging tree, and for verifying the endpoint is switchable
+# without rebuilding. Leave it unset for normal use.
+#
+# DATA_URL=http://localhost:8899
diff --git a/apps/www/.env.local b/apps/www/.env.local
new file mode 100644
index 0000000000..6f3143c2ff
--- /dev/null
+++ b/apps/www/.env.local
@@ -0,0 +1,21 @@
+# Base URL of the served benchmark data tree (@benchmarks/data).
+#
+# NEXT_PUBLIC_DATA_URL is read at BUILD time. Next constant-folds every
+# NEXT_PUBLIC_* read into the bundle, so changing this value requires a
+# rebuild -- `next start` will not pick it up.
+#
+# Local development, after `vlt run --workspace=apps/data build`:
+# vlt run --workspace=apps/data start # serves results/ on :8811
+#
+# Production is the gh-pages origin, which serves the same tree:
+# NEXT_PUBLIC_DATA_URL=https://benchmarks.vlt.sh
+NEXT_PUBLIC_DATA_URL=http://localhost:8811
+
+# Optional server-side runtime override, checked first when set.
+#
+# Every fetch in this app happens on the server, so this one is NOT inlined and
+# does repoint an already-built app at another origin. Useful for pointing a
+# single build at a staging tree, and for verifying the endpoint is switchable
+# without rebuilding. Leave it unset for normal use.
+#
+# DATA_URL=http://localhost:8899
diff --git a/apps/www/.gitignore b/apps/www/.gitignore
new file mode 100644
index 0000000000..f24d48d91c
--- /dev/null
+++ b/apps/www/.gitignore
@@ -0,0 +1,3 @@
+# Next.js build output and the type shim it generates on first build.
+.next/
+next-env.d.ts
diff --git a/apps/www/AGENTS.md b/apps/www/AGENTS.md
new file mode 100644
index 0000000000..643577dfae
--- /dev/null
+++ b/apps/www/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/apps/www/components.json b/apps/www/components.json
new file mode 100644
index 0000000000..3289f237ad
--- /dev/null
+++ b/apps/www/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/apps/www/next.config.ts b/apps/www/next.config.ts
new file mode 100644
index 0000000000..21c7683922
--- /dev/null
+++ b/apps/www/next.config.ts
@@ -0,0 +1,79 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import createMDX from "@next/mdx";
+import type { NextConfig } from "next";
+
+/**
+ * The workspace root, two levels up from `apps/www`.
+ *
+ * Turbopack otherwise infers its filesystem root as this app's own directory
+ * and refuses to compile anything above it. vlt installs into a content-
+ * addressed store at the workspace root and links into each member, so `next`
+ * itself resolves through `apps/www/node_modules/next` to
+ * `/node_modules/.vlt/...`. Without this the build fails with
+ * "Could not find the Next.js package".
+ */
+const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+
+const acceptsMarkdown = {
+ type: "header",
+ key: "accept",
+ value: ".*text/markdown.*",
+} as const;
+
+/*
+ * Extensionless pages, as the docs site has them. Skips the markdown route
+ * itself, the API, MCP and social-card endpoints, Next's own assets, and anything with a
+ * file extension (`.md` urls, llms.txt, openapi.json), which has no markdown
+ * twin.
+ */
+const pages = "/:path((?!llms\\.mdx/|(?:api|_next|mcp|og)(?:/|$))(?!.*\\.[a-z0-9]+$).+)";
+
+const nextConfig: NextConfig = {
+ // Pages prerender a shell around cached data (lib/data.ts), and links prefetch that shell.
+ cacheComponents: true,
+ partialPrefetching: true,
+ // The About and Methodology pages are MDX (app/*/content.mdx).
+ pageExtensions: ["ts", "tsx", "mdx"],
+ // Both workspace packages ship TypeScript source with no build step, so Next
+ // has to transpile them itself. Without this the build fails resolving
+ // `@benchmarks/data`'s `.ts` relative imports.
+ transpilePackages: ["@benchmarks/types", "@benchmarks/data"],
+ // takumi's native renderer (app/og) is loaded by Node, not bundled -- as vlt.io has it.
+ serverExternalPackages: ["@takumi-rs/image-response"],
+ turbopack: { root: workspaceRoot },
+ // The docs' rewrites (vltpkg/www/docs/next.config.ts). The query survives a
+ // rewrite, so `/x.md?fixture=astro` is the markdown of that filtered view.
+ rewrites: async () => ({
+ // Agents that ask for markdown get it at the page's own url; runs before
+ // the filesystem, which would otherwise match the html page first.
+ beforeFiles: [
+ { source: "/", has: [acceptsMarkdown], destination: "/llms.mdx" },
+ { source: pages, has: [acceptsMarkdown], destination: "/llms.mdx/:path" },
+ ],
+ // `/package-managers/clean.md` serves that page as plain markdown.
+ afterFiles: [{ source: "/:path+.md", destination: "/llms.mdx/:path+" }],
+ }),
+ // Those urls answer with html or markdown depending on `Accept`, so caches must key on it.
+ headers: async () =>
+ ["/", pages].map((source) => ({
+ source,
+ headers: [{ key: "Vary", value: "Accept" }],
+ })),
+ // Compare was removed; its links land on the same section's report.
+ redirects: async () => [
+ /* The front door is the default benchmark; what used to be here is /about.
+ Temporary, so the default can change without browsers caching it. */
+ { source: "/", destination: "/registries/average", permanent: false },
+ /* A benchmark opens on its average, query kept; temporary for the same reason. */
+ {
+ source: "/:section(registries|package-managers|task-runners)",
+ destination: "/:section/average",
+ permanent: false,
+ },
+ { source: "/compare", destination: "/package-managers", permanent: true },
+ { source: "/compare/:section/:rest*", destination: "/:section", permanent: true },
+ ],
+};
+
+export default createMDX()(nextConfig);
diff --git a/apps/www/package.json b/apps/www/package.json
new file mode 100644
index 0000000000..1f3bc686fe
--- /dev/null
+++ b/apps/www/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@benchmarks/www",
+ "version": "0.0.0",
+ "private": true,
+ "description": "The benchmarks site. A Next.js app that renders the published gh-pages data, fetched over HTTP through @benchmarks/data, as HTML, markdown and MCP.",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint:check": "oxlint src test",
+ "lint": "oxlint --fix src test",
+ "format:check": "oxfmt --check src test",
+ "format": "oxfmt src test",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@benchmarks/data": "workspace:*",
+ "@benchmarks/types": "workspace:*",
+ "@mdx-js/loader": "^3.1.1",
+ "@mdx-js/react": "^3.1.1",
+ "@modelcontextprotocol/server": "^2.3.1",
+ "@next/mdx": "^16.4.1",
+ "@takumi-rs/image-response": "^0.69.2",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "echarts": "^6.0.0",
+ "lucide-react": "^1.50.0",
+ "mcp-handler": "^2.3.0",
+ "motion": "^12.23.26",
+ "next": "^16.3.8",
+ "next-themes": "^0.4.6",
+ "nuqs": "^2.10.2",
+ "radix-ui": "^1.6.7",
+ "react": "^19.2.0",
+ "react-day-picker": "^10.0.2",
+ "react-dom": "^19.2.0",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.3.3",
+ "zod": "^4.6.7"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.3.3",
+ "@types/mdx": "^2.0.14",
+ "@types/node": "catalog:hygiene",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.1",
+ "oxfmt": "catalog:hygiene",
+ "oxlint": "catalog:hygiene",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "catalog:hygiene",
+ "vitest": "catalog:testing"
+ }
+}
diff --git a/apps/www/postcss.config.mjs b/apps/www/postcss.config.mjs
new file mode 100644
index 0000000000..61e36849cf
--- /dev/null
+++ b/apps/www/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/app/src/assets/fonts/GeistMono-VariableFont_wght.ttf b/apps/www/public/assets/fonts/geist-mono.ttf
similarity index 100%
rename from app/src/assets/fonts/GeistMono-VariableFont_wght.ttf
rename to apps/www/public/assets/fonts/geist-mono.ttf
diff --git a/apps/www/public/assets/fonts/inter.ttf b/apps/www/public/assets/fonts/inter.ttf
new file mode 100644
index 0000000000..5c88739bdf
Binary files /dev/null and b/apps/www/public/assets/fonts/inter.ttf differ
diff --git a/apps/www/public/images/marketing/open-source-initiative.png b/apps/www/public/images/marketing/open-source-initiative.png
new file mode 100644
index 0000000000..07f8796044
Binary files /dev/null and b/apps/www/public/images/marketing/open-source-initiative.png differ
diff --git a/apps/www/src/app/[section]/[[...params]]/page.tsx b/apps/www/src/app/[section]/[[...params]]/page.tsx
new file mode 100644
index 0000000000..ef2258a190
--- /dev/null
+++ b/apps/www/src/app/[section]/[[...params]]/page.tsx
@@ -0,0 +1,722 @@
+import type { Metadata } from "next";
+import { Fragment, Suspense } from "react";
+import { notFound, redirect } from "next/navigation";
+import { ArrowRight } from "lucide-react";
+import { InlineCode, Marked } from "@/components/inline-code";
+import { Button } from "@/components/ui/button";
+import { TimeSeriesChart } from "@/components/charts-echarts";
+import { BarFigure, GroupedBarFigure } from "@/components/charts-figures";
+import { ToolLegend } from "@/components/chart-legend";
+import { FixtureTimeSeries } from "@/components/fixture-trend";
+import { TabbedFigure } from "@/components/tabbed-figure";
+import { NoData } from "@/components/no-data";
+import { Callout, Figure, ReportSection, Stat } from "@/components/report";
+import { DataAndSource } from "@/components/raw-results";
+import { FixtureMenu } from "@/components/fixture-menu";
+import { PathSkeleton } from "@/components/path-skeleton";
+import { PendingLink, PendingProvider, PendingRegion } from "@/components/report-pending";
+import {
+ DESCRIPTION,
+ FINDING,
+ FIXTURE_MENU,
+ MENUS,
+ ReportHead,
+ ReportIntro,
+ TITLE,
+ TOOLS_MENU,
+ TOP,
+ Trail,
+} from "@/components/report-shell";
+import { SelectionChips, type ServerChip } from "@/components/selection-chips";
+import { ShowAllTools, ToolsMenu } from "@/components/tools-menu";
+import { CountTable, Leaderboard, PerFixtureTable, TrendByTool } from "@/components/sections";
+import { TOCMinimap } from "@/components/toc-minimap";
+import { FixtureName, ToolName } from "@/components/tool-name";
+import { formatDuration, longDate } from "@/lib/findings";
+import { fixtureBars } from "@/lib/figures";
+import { pageMetadata } from "@/lib/metadata";
+import { ogFigure } from "@/lib/og-figure";
+import { loadReportParams, reportHref, reportParams } from "@/lib/params";
+import { loadCatalog } from "@/lib/catalog";
+import { dataClient } from "@/lib/data";
+import { loadReport } from "@/lib/report";
+import { withAverage } from "@/lib/scenarios";
+import { isSectionId, SECTION_DEFS, SECTIONS } from "@/lib/sections";
+import { fixtureLabel, toolLabel } from "@/lib/tools";
+
+/* Every scenario of the latest run, so each prerenders its shell; any other renders it on demand. */
+export const generateStaticParams = async () => {
+ const { scenarios } = await loadCatalog(dataClient());
+ return SECTIONS.flatMap((section) =>
+ withAverage(scenarios[section]).map((s) => ({ section, params: [s.variation] })),
+ );
+};
+
+/** "Package managers — vlt benchmarks": the section names the page, whatever the scenario. */
+export const generateMetadata = async ({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ section: string; params?: string[] }>;
+ searchParams: Promise;
+}): Promise => {
+ const { section, params: rest = [] } = await params;
+ if (!isSectionId(section)) return {};
+ const def = SECTION_DEFS[section];
+ const path = ["", section, ...rest].map(encodeURIComponent).join("/");
+ /* The card shows this run and fixture; every other parameter changes nothing on it --
+ but a link to one figure (`?figure=`, see lib/og-figure.ts) gets that figure's card,
+ over the window and without the tools hidden on the page it links to. */
+ const query = await loadReportParams(searchParams);
+ const { fixture, date } = query;
+ const raw = (await searchParams)["figure"];
+ const figure = ogFigure(typeof raw === "string" ? raw : null);
+ const card = new URL(
+ reportHref(`/og${path}`, {
+ fixture,
+ date,
+ ...(figure === null
+ ? {}
+ : { hide: query.hide, range: query.range, from: query.from, to: query.to }),
+ }),
+ "http://card",
+ );
+ if (figure !== null) card.searchParams.set("figure", figure);
+ const image = `${card.pathname}${card.search}`;
+ return pageMetadata({
+ title: def.label,
+ description: def.about,
+ path,
+ image,
+ });
+};
+/**
+ * A benchmark's report, for one scenario.
+ *
+ * The BENCHMARK and the SCENARIO are chosen in the sidebar (or its search,
+ * ⌘K). The page opens on one row, level with the sidebar's search: the
+ * breadcrumb trail on the left, the tools and fixture menus on the right.
+ * Beneath it any other non-default selection as a chip that undoes it, then
+ * the scenario's badge and what was found, as the page's h1. Under it what was
+ * on disk before each run, and what the scenario is, running into the lede.
+ *
+ * `/{section}` redirects to `/{section}/average`, query kept (next.config.ts):
+ * the derived view, the mean of the measured scenarios. Where there are fewer
+ * than two to average, the report redirects on to the section's first scenario.
+ *
+ * Everything here but the report is the shell, shared by every link to a
+ * report and prefetched; the report reads the address and streams in.
+ */
+const Page = ({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ section: string; params?: string[] }>;
+ searchParams: Promise;
+}) => (
+
+
+
+ }
+ >
+
+
+);
+
+type SearchParams = { [key: string]: string | string[] | undefined };
+
+/** The report itself: everything that waits on the data. */
+const Report = async ({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ section: string; params?: string[] }>;
+ searchParams: Promise;
+}) => {
+ const { section, params: rest } = await params;
+ if (!isSectionId(section)) notFound();
+ const requested = rest?.[0] === undefined ? undefined : decodeURIComponent(rest[0]);
+ const def = SECTION_DEFS[section];
+ const query = await loadReportParams(searchParams);
+ const result = await loadReport({ section, scenario: requested, query });
+
+ if (result.status === "unknown-section") notFound();
+ if (result.status === "no-dates") {
+ return (
+ <>
+
+
{def.label}
+
+
+ The served tree reports no dates. There is nothing to show.
+
+ >
+ );
+ }
+ /*
+ * `/{section}` opens on the section's average (or its first scenario), and
+ * so does a scenario this date did not publish (a stale link, a pinned old
+ * run): by redirect, query kept, so the address -- and the sidebar's current
+ * item -- name what is on screen. Temporary: whether a date has an average
+ * depends on what it published.
+ */
+ if (result.status === "unknown-scenario") redirect(result.href);
+
+ const pinned = result.status === "ok" ? result.report.filters.date : null;
+ const sectionHref = reportHref(`/${section}`, { ...query, date: pinned });
+
+ if (result.status === "no-results") {
+ return (
+ <>
+
+
+
+
+
{def.label}
+
+
No {def.label.toLowerCase()} results in this run.
+
+ >
+ );
+ }
+
+ const report = result.report;
+ const { scenario, run, filters, finding, baseline, caveats, trend } = report;
+ const { date } = run;
+ const { fixture, hide: hidden } = filters;
+ const { measure, isScript, outline, ranked, publishedFixtures, path } = report;
+
+ const visible = (tool: string) => !hidden.includes(tool);
+ const name = (tool: string) => toolLabel(tool, section);
+ const named = (tool: string) => ({ tool, label: name(tool) });
+ const baselineName = baseline === null ? null : name(baseline);
+
+ const timeBars = report.timeBars;
+ const perPackageBars = report.perPackageBars;
+ /* What the chart needs of each run besides its plotted means. */
+ const trendDetail = trend.points.map(
+ ({
+ byTool: _byTool,
+ perPackage: _perPackage,
+ perPackageByFixture: _perPackageByFixture,
+ ...rest
+ }) => rest,
+ );
+ const fixtureCount = report.fixtures.length;
+ const across =
+ fixture !== null
+ ? `on the ${fixtureLabel(fixture)} fixture`
+ : `across ${fixtureCount} ${fixtureCount === 1 ? "fixture" : "fixtures"}`;
+ /* Axis titles: the quantity and what it is taken over, in words. */
+ const over = fixture !== null ? `on ${fixtureLabel(fixture)}` : `across ${fixtureCount} fixtures`;
+ const timeAxis =
+ fixtureCount === 1
+ ? `${isScript ? "Execution" : "Install"} time ${over}`
+ : `Mean ${measure} time ${over}`;
+ const toolAxis =
+ section === "registries"
+ ? "Registry"
+ : section === "task-runners"
+ ? "Task runner"
+ : "Package manager";
+
+ /** A section holding one table is headed by that table: no title said twice. */
+ const solo = new Set(
+ outline
+ .filter((part) => part.items.length === 1 && part.items[0]?.kind === "table")
+ .flatMap((part) => part.items.map((item) => item.id)),
+ );
+ const level = (id: string): 2 | 3 => (solo.has(id) ? 2 : 3);
+
+ /**
+ * What a bar figure's guide may say about the baseline: only what is on
+ * screen. The dashed outline and rule are described only if the baseline's
+ * bar is drawn. If the baseline is hidden, neither is drawn, and the guide
+ * says only what the multiples are still against. If this figure has no
+ * baseline at all, it says nothing.
+ */
+ const baselineGuide = (bars: readonly { tool: string }[]): string => {
+ if (baselineName === null || !bars.some((b) => b.tool === baseline)) return "";
+ return visible(baseline ?? "")
+ ? ` Dashed outline and dashed rule: the baseline, ${baselineName}.`
+ : ` Multiples are against the baseline, ${baselineName}, which is hidden.`;
+ };
+
+ /* Each fixture's bars, for the reader's tools: hidden tools are neither drawn nor named. */
+ const onEachFixture = (rows: typeof report.timeRows) =>
+ fixtureBars(rows).map((f) => ({
+ fixture: f.fixture,
+ bars: f.bars
+ .filter((b) => visible(b.tool))
+ .map((b) => ({ ...named(b.tool), value: b.value })),
+ failed: f.failed.filter(visible).map(name),
+ reference: f.bars.find((b) => b.tool === baseline)?.value,
+ }));
+ /* Every tool these files hold, hidden ones too: the legends list them all. */
+ const byFixtureTools = [...ranked, ...report.unranked].map(named);
+
+ const bodies: { [itemId: string]: React.ReactNode } = {
+ "leaderboard-table": (
+
+ Lower is faster. ± is the pooled relative standard deviation.
+ {trend.points.length > 1
+ ? ` Since last run compares with ${longDate(trend.points.at(-2)?.date ?? date)}.`
+ : ""}
+ >
+ }
+ notes={caveats.leaderboard}
+ >
+
+
+ ),
+ "trend-by-tool": (
+
+
+
+ ),
+ "time-bars": (
+
+ Lower is faster. Bars start at zero, each a bright cap on its tool's colour,
+ labelled with its time and its multiple of the baseline.
+ {baselineGuide(timeBars.bars)}
+ >
+ }
+ notes={caveats.timeBars}
+ >
+ visible(b.tool))
+ .map((b) => ({ ...named(b.tool), value: b.value }))}
+ unit="s"
+ baseline={baseline}
+ reference={timeBars.bars.find((b) => b.tool === baseline)?.value}
+ xLabel={timeAxis}
+ yLabel={toolAxis}
+ variant="stripped"
+ />
+
+ ),
+ "time-trend": (
+
+ ),
+ "time-by-fixture": (
+
+ Lower is faster; bars start at zero, each a bright cap on its tool's colour. All
+ fixtures: a group per fixture, the tools in leaderboard order, as in the legend; hover a
+ fixture to read each by name. Each fixture: its tools fastest first, each labelled with
+ its time. A tool is drawn on every fixture it finished; a failed run is named, not
+ drawn. The legend shows and hides tools everywhere on the page.
+ {baselineGuide(timeBars.bars)}
+ >
+ }
+ tabs={[
+ {
+ value: "all",
+ label: "All fixtures",
+ content: (
+ ({
+ fixture: r.fixture,
+ label: fixtureLabel(r.fixture),
+ values: Object.fromEntries(
+ r.values.filter((v) => visible(v.tool)).map((v) => [v.tool, v.value]),
+ ),
+ failed: r.failed.filter(visible),
+ }))}
+ tools={byFixtureTools.filter((t) => visible(t.tool))}
+ unit="s"
+ yLabel={`${isScript ? "Execution" : "Install"} time`}
+ legend={
+ t.tool)}
+ values={Object.fromEntries(
+ timeBars.bars.map((b) => [b.tool, `${formatDuration(b.value)} mean`]),
+ )}
+ section={section}
+ baseline={baseline}
+ />
+ }
+ />
+ ),
+ },
+ ...onEachFixture(report.timeRows).map((f) => ({
+ value: f.fixture,
+ label: ,
+ /* Keyed: elements made in a map are validated as a list's children. */
+ content: (
+
+ t.tool)}
+ values={Object.fromEntries(
+ report.timeRows
+ .find((r) => r.fixture === f.fixture)
+ ?.values.map((v) => [v.tool, formatDuration(v.value)]) ?? [],
+ )}
+ section={section}
+ baseline={baseline}
+ />
+ }
+ />
+ {f.failed.length === 0 ? null : (
+
+ Did not finish, not drawn: {f.failed.join(", ")}
+
+ )}
+
+ ),
+ })),
+ ]}
+ />
+ ),
+ "time-table": (
+
+ Each cell is the mean time
+ {baselineName === null
+ ? ""
+ : `, with its multiple of ${baselineName} on that fixture beneath`}
+ . Bold: fastest on the fixture. DNF: the run exited non-zero and is excluded from every
+ figure. A dash: not run on that fixture.
+ >
+ }
+ >
+
+
+ ),
+ "per-package-chart": (
+
+ Milliseconds per installed package; lower is faster. Tools disagree about what a
+ dependency graph contains, so a tool that installs fewer packages finishes sooner
+ without being faster — this is the figure that corrects for it.
+ {baselineGuide(perPackageBars.bars)}
+ >
+ }
+ notes={caveats.perPackageBars}
+ >
+ visible(b.tool))
+ .map((b) => ({ ...named(b.tool), value: b.value }))}
+ unit="ms"
+ baseline={baseline}
+ reference={perPackageBars.bars.find((b) => b.tool === baseline)?.value}
+ xLabel={`${fixtureCount === 1 ? "Time" : "Mean time"} per installed package ${over}`}
+ yLabel={toolAxis}
+ />
+
+ ),
+ "per-package-by-fixture": (
+
+ ),
+ "per-package-table": (
+
+
+
+ ),
+ "package-count": (
+
+
+
+ ),
+ "process-count": (
+
+
+
+ ),
+ };
+
+ /* The non-default selections the menus beside them do not already show,
+ as resolved: a value the page ignored gets no chip. */
+ const chips: ServerChip[] = [
+ ...(filters.window.custom
+ ? [
+ {
+ label: "window",
+ value: `${longDate(filters.window.from)} – ${longDate(filters.window.to)}`,
+ clear: ["from", "to", "range"] as const,
+ },
+ ]
+ : query.range === reportParams.range.defaultValue
+ ? []
+ : [
+ {
+ label: "window",
+ value: `${query.range} days`,
+ clear: ["range", "from", "to"] as const,
+ },
+ ]),
+ ...(trend.focus === null
+ ? []
+ : [
+ {
+ label: "focus",
+ value: `${longDate(trend.focus.from)} – ${longDate(trend.focus.to)}`,
+ clear: ["focus"] as const,
+ },
+ ]),
+ ...(filters.exclude.length === 0
+ ? []
+ : [
+ {
+ label: "exclusions",
+ value: String(filters.exclude.length),
+ clear: ["exclude"] as const,
+ },
+ ]),
+ ];
+ const metric =
+ query.metric === "total" || (query.metric === "per-package" && !trend.perPackage)
+ ? null
+ : query.metric;
+
+ return (
+
+
+
+ {publishedFixtures.length < 2 ? null : (
+
+ )}
+
+ }
+ chips={
+ p.date)} />
+ }
+ />
+
+
+
+ Mean {measure} time {across}
+ {scenario.averageOf === null ? (
+ <>
+ {" "}
+ in the {scenario.variation} scenario
+ >
+ ) : (
+ `, averaged over ${scenario.averageOf.scenarios.length} scenarios`
+ )}
+ , from the run of {longDate(date)} on GitHub-hosted{" "}
+ ubuntu-24.04-arm runners.
+ {section === "registries"
+ ? " Every registry is installed from with the same npm client."
+ : ""}
+ {run.isLatest ? null : " This is not the latest run."}
+ >
+ }
+ action={
+ run.isLatest ? undefined : (
+
+ )
+ }
+ />
+ {caveats.qualifier === null ? null : (
+
+);
+
+/** The agent prompt, in llms.txt's words (app/llms.txt/route.ts): what to read, the API, the MCP server, how to read a result. */
+const agentPrompt = (
+ origin: string,
+): string => `Help me use the vlt benchmarks. Start by reading ${origin}/llms.txt, which lists every page of the site and explains how to read the results. Every page is available as Markdown: append .md to its URL, or request the page with an Accept: text/markdown header. Query parameters apply to the Markdown as they do to the page: ${origin}/package-managers/clean.md?fixture=babylon is that scenario on one fixture. ${origin}/llms-full.txt holds every scenario of the latest run, at the default view, in one file.
+
+1. For structured data, use the JSON API, described at ${origin}/openapi.json: /api/v1/runs, /api/v1/catalog, /api/v1/{section}/{scenario} (the full report, same parameters as the page) and /api/v1/{section}/{scenario}/history (the trend, failures and coverage).
+2. To query it as tools, add the read-only MCP server with \`claude mcp add --transport http vlt-benchmarks ${origin}/mcp\`. Its tools are list_benchmarks, get_results, get_history and compare_scenarios.
+3. When you report a result: total time is the measurement and the ranking. A DNF (the run exited non-zero) is never counted as a time, a tool that did not complete every fixture is not ranked against tools that did, and derived metrics are labelled "derived".
+
+Only quote numbers that appear on the site, and name a run by its date.`;
+
+type Card = {
+ icon: LucideIcon;
+ title: string;
+ description: string;
+ /** Absent for a surface a browser cannot open (the MCP endpoint). */
+ href?: string;
+};
+
+const cards: Card[] = [
+ {
+ icon: BookOpenIcon,
+ title: "llms.txt",
+ description: "An index of every page on this site, for agents to read before anything else.",
+ href: "/llms.txt",
+ },
+ {
+ icon: FileTextIcon,
+ title: "Markdown pages",
+ description:
+ "Add .md to any page's URL for a plain Markdown copy, or read every scenario of the latest run in one file.",
+ href: "/llms-full.txt",
+ },
+ {
+ icon: BracesIcon,
+ title: "JSON API",
+ description:
+ "Runs, the catalog, and each scenario's full report and history, described by an OpenAPI document.",
+ href: "/openapi.json",
+ },
+ {
+ icon: PlugIcon,
+ title: "MCP server",
+ description:
+ "Read-only tools at /mcp to list benchmarks, get results and history, and compare scenarios.",
+ },
+];
+
+/* The docs' card grid (vltpkg/www/docs/src/app/page.tsx), verbatim but for the card without a link. */
+const CardGrid = ({ cards }: { cards: Card[] }) => (
+
+);
+
+/* Who publishes the site and what it holds, for agents and search engines (schema.org JSON-LD), in
+ the docs' shape. The organisation is the docs' own; the dataset claims nothing a run could falsify. */
+const jsonLd = (origin: string, latest: string | null) => {
+ const org = {
+ "@type": "Organization",
+ "@id": "https://www.vlt.io/#organization",
+ name: "vlt",
+ legalName: "vlt technology inc",
+ url: "https://www.vlt.io",
+ logo: new URL("/icon1.png", origin).href,
+ sameAs: [
+ "https://github.com/vltpkg",
+ "https://www.linkedin.com/company/vltpkg/",
+ "https://x.com/vltpkg",
+ "https://bsky.app/profile/vlt.sh",
+ ],
+ };
+ return {
+ "@context": "https://schema.org",
+ "@graph": [
+ org,
+ {
+ "@type": "WebSite",
+ name: SITE_NAME,
+ url: `${origin}/`,
+ description: SITE_DESCRIPTION,
+ publisher: { "@id": org["@id"] },
+ },
+ {
+ "@type": "Dataset",
+ name: SITE_NAME,
+ description: SITE_DESCRIPTION,
+ url: `${origin}/`,
+ ...(latest === null ? {} : { dateModified: latest }),
+ publisher: { "@id": org["@id"] },
+ creator: { "@id": org["@id"] },
+ distribution: [
+ {
+ "@type": "DataDownload",
+ encodingFormat: "application/json",
+ contentUrl: `${origin}/api/v1/catalog`,
+ },
+ {
+ "@type": "DataDownload",
+ encodingFormat: "text/markdown",
+ contentUrl: `${origin}/llms-full.txt`,
+ },
+ ],
+ },
+ ],
+ };
+};
+
+export const JsonLd = async () => {
+ const origin = siteOrigin();
+ const index = await dataClient().getIndex();
+ return (
+
+ );
+};
+
+/* The docs' home page hero (vltpkg/www/docs/src/app/page.tsx): the title, the lede, a way into each benchmark. */
+export const Hero = ({ title, lede }: { title: string; lede: string }) => (
+ /* the docs' inset is py-8 md:py-12; this one is pt-4 so report pages line up with the sidebar's search */
+
+ {/* Behind the text, larger than the hero and free to overlap it; a fade from the page
+ colour keeps the words readable wherever the two meet. Nudged down so it clears the
+ header rather than tucking under it. */}
+
+
+
+
+
+
+ {title}
+
+
{lede}
+
+ {SECTIONS.map((id, i) => (
+
+ ))}
+
+
+
+);
+
+/** The agent prompt, with the reader's origin in it, and the ways in it names. */
+export const AgentSetup = async () => (
+
+
+
+
+
+
+
+
+);
+
+/**
+ * What each benchmark found in the latest run.
+ *
+ * Each finding is the SAME sentence its own page leads with, computed by the
+ * same function from the same files (`lib/headline.ts`) for that section's
+ * first scenario -- the one `/{section}` opens on -- and links to that
+ * scenario's page. So a reader who follows a link lands on the heading they
+ * just read, and the two cannot disagree.
+ *
+ * A section the latest run published nothing for says so. It does not borrow
+ * an older run's finding to fill the space.
+ */
+export const LatestResults = async () => {
+ const client = dataClient();
+ const index = await client.getIndex();
+ const date = index.latest;
+
+ const headlines =
+ date === null
+ ? []
+ : await (async () => {
+ const entry = await client.getDirectoryEntry(date);
+ const published = [...new Set(Object.values(entry.benchmarks).flatMap((v) => v ?? []))];
+ return Promise.all(
+ SECTIONS.map(
+ async (section): Promise => {
+ const scenarios = scenariosFor(section, published);
+ const scenario = scenarios[0];
+ if (scenario === undefined) return { section, scenario: null };
+ const fixtures = fixturesWith(entry.benchmarks, scenario.variation);
+ const data = await loadScenario(client, date, scenario.variation, fixtures, []);
+ return {
+ section,
+ scenario,
+ scenarioCount: scenarios.length,
+ fixtureCount: fixtures.length,
+ facts: scenarioFacts(section, data.bundles),
+ };
+ },
+ ),
+ );
+ })();
+
+ return (
+
+
+ {date === null ? (
+ "The served tree reports no dates. There is nothing to show."
+ ) : (
+ <>
+ From the nightly run of . Each finding is
+ the headline of its benchmark's report.
+ >
+ )}
+
+ The run of {longDate(date ?? "")} published nothing for this benchmark.
+
+
+ ) : (
+ /* The figures' own container: the scenario is the tab, the
+ finding and its ranking the panel, the counts and the way
+ in the footer. */
+
+ {label}
+
+ /
+
+
+ }
+ footer={
+ <>
+
+
+
+
+
+
+ >
+ }
+ >
+
+
+ {/* The finding at the top, its notes at the foot, so the column fills
+ the height the ranking beside it takes. */}
+
+ );
+};
+
+/**
+ * The ranking in miniature: one horizontal bar per tool, fastest first, in
+ * plain HTML. Only a tool that completed every fixture gets a bar (`meanBars`,
+ * as the report's bar figure); the rest are named, not drawn.
+ */
+const Ranking = ({ section, facts }: { section: SectionId; facts: ScenarioFacts }) => {
+ const { bars } = meanBars(facts.timeRows);
+ const shown = shownBars(bars, facts.baseline);
+ const max = Math.max(...shown.map((b) => b.value));
+ const name = (tool: string) => toolLabel(tool, section);
+ return (
+
+ {/* Drawn for the eye; the list below carries the same for assistive technology. */}
+
+ {shown.map((b) => (
+
+
+ {/* Zero-based: a bar's length is the number. The report's stripped bar: a bright
+ cap on a dimmed body. The baseline is a dashed outline, as on the report. */}
+
+
+ {formatDuration(b.value)}
+ {b.tool === facts.baseline ? (
+ baseline
+ ) : null}
+
+
+ );
+};
+
+/* What the ranking leaves out, said beside the finding rather than under the bars. */
+const RankingNote = ({
+ section,
+ scenario,
+ facts,
+}: {
+ section: SectionId;
+ scenario: string;
+ facts: ScenarioFacts;
+}) => {
+ const { bars, partial } = meanBars(facts.timeRows);
+ const rest = bars.length - shownBars(bars, facts.baseline).length;
+ const name = (tool: string) => toolLabel(tool, section);
+ return partial.length === 0 && rest === 0 ? null : (
+
+ {partial.length === 0
+ ? null
+ : `Not drawn: ${partial.map(name).join(", ")}, which did not complete every fixture. `}
+ {rest === 0 ? null : (
+ <>
+ And {rest} more in the{" "}
+
+ full ranking
+
+ .
+ >
+ )}
+
+ );
+};
diff --git a/apps/www/src/app/about/content.mdx b/apps/www/src/app/about/content.mdx
new file mode 100644
index 0000000000..c25b2bc45b
--- /dev/null
+++ b/apps/www/src/app/about/content.mdx
@@ -0,0 +1,14 @@
+
+
+## Set up your agent
+
+Every page on this site is also available as Markdown, and `/llms.txt` indexes them all. Paste this prompt into your agent to point it at the benchmarks:
+
+
+
+## Latest results
+
+
diff --git a/apps/www/src/app/about/page.tsx b/apps/www/src/app/about/page.tsx
new file mode 100644
index 0000000000..f2e46c63f1
--- /dev/null
+++ b/apps/www/src/app/about/page.tsx
@@ -0,0 +1,42 @@
+import type { ComponentProps } from "react";
+import { Heading } from "@/components/heading";
+import { TOCMinimap } from "@/components/toc-minimap";
+import { mdxSource, tocOf } from "@/lib/mdx";
+import { pageMetadata, SITE_DESCRIPTION } from "@/lib/metadata";
+import { AgentSetup, Hero, JsonLd, LatestResults } from "./components";
+import Content from "./content.mdx";
+
+/* The site's front page: what the benchmarks are, how agents read them, and the
+ latest headline of each. `/` itself opens on the default benchmark. Its
+ Markdown twin is llms.txt. */
+export const metadata = pageMetadata({
+ title: "About",
+ description: SITE_DESCRIPTION,
+ path: "/about",
+});
+
+/* The landing page's own scale, in the docs' home page's shape rather than an article's. */
+const components = {
+ h2: ({ ref: _ref, ...props }: ComponentProps<"h2">) => (
+
+ ),
+ p: ({ ref: _ref, ...props }: ComponentProps<"p">) => (
+
+ ),
+ Hero,
+ AgentSetup,
+ LatestResults,
+};
+
+const About = () => (
+ <>
+ {/* A landing page, not an article: the body's size and leading, and no trim of the hero's top margin. */}
+
+
+
+
+
+ >
+);
+
+export default About;
diff --git a/apps/www/src/app/api/[...path]/route.ts b/apps/www/src/app/api/[...path]/route.ts
new file mode 100644
index 0000000000..a851a863a2
--- /dev/null
+++ b/apps/www/src/app/api/[...path]/route.ts
@@ -0,0 +1,21 @@
+// unknown `/api/*` paths would otherwise render the html 404 page, which API clients can't parse;
+// the real routes under `api/v1/` are more specific and win
+const notFound = async (request: Request, { params }: { params: Promise<{ path: string[] }> }) =>
+ Response.json(
+ {
+ error: {
+ code: "not_found",
+ message: `No API endpoint at ${request.method} /api/${(await params).path.join("/")}.`,
+ hint: "The API is described at /openapi.json. Benchmark pages are indexed at /llms.txt.",
+ },
+ },
+ { status: 404 },
+ );
+
+export {
+ notFound as GET,
+ notFound as POST,
+ notFound as PUT,
+ notFound as PATCH,
+ notFound as DELETE,
+};
diff --git a/apps/www/src/app/api/v1/[section]/[scenario]/history/route.ts b/apps/www/src/app/api/v1/[section]/[scenario]/history/route.ts
new file mode 100644
index 0000000000..89070c9f8b
--- /dev/null
+++ b/apps/www/src/app/api/v1/[section]/[scenario]/history/route.ts
@@ -0,0 +1,17 @@
+import { errorResponse, historyOf, strictReport, unknownTools } from "@/lib/api";
+import { loadReportParams } from "@/lib/params";
+
+/** One scenario's trend, failures and coverage, narrowed to `tools` (comma-separated). */
+export const GET = async (
+ request: Request,
+ { params }: { params: Promise<{ section: string; scenario: string }> },
+) => {
+ const { section, scenario } = await params;
+ const search = new URL(request.url).searchParams;
+ const query = loadReportParams(search);
+ const tools = search.get("tools")?.split(",").filter(Boolean) ?? [];
+ const result = await strictReport({ section, scenario: decodeURIComponent(scenario), query });
+ if ("error" in result) return errorResponse(result.error);
+ const unknown = unknownTools(result.report, tools);
+ return unknown === null ? Response.json(historyOf(result.report, tools)) : errorResponse(unknown);
+};
diff --git a/apps/www/src/app/api/v1/[section]/[scenario]/route.ts b/apps/www/src/app/api/v1/[section]/[scenario]/route.ts
new file mode 100644
index 0000000000..238c98153a
--- /dev/null
+++ b/apps/www/src/app/api/v1/[section]/[scenario]/route.ts
@@ -0,0 +1,13 @@
+import { errorResponse, strictReport } from "@/lib/api";
+import { loadReportParams } from "@/lib/params";
+
+/** One scenario's full report, with the same query parameters as its page. */
+export const GET = async (
+ request: Request,
+ { params }: { params: Promise<{ section: string; scenario: string }> },
+) => {
+ const { section, scenario } = await params;
+ const query = loadReportParams(new URL(request.url).searchParams);
+ const result = await strictReport({ section, scenario: decodeURIComponent(scenario), query });
+ return "error" in result ? errorResponse(result.error) : Response.json(result.report);
+};
diff --git a/apps/www/src/app/api/v1/catalog/route.ts b/apps/www/src/app/api/v1/catalog/route.ts
new file mode 100644
index 0000000000..f47f351678
--- /dev/null
+++ b/apps/www/src/app/api/v1/catalog/route.ts
@@ -0,0 +1,4 @@
+import { describeCatalog } from "@/lib/catalog";
+import { dataClient } from "@/lib/data";
+
+export const GET = async () => Response.json(await describeCatalog(dataClient()));
diff --git a/apps/www/src/app/api/v1/runs/route.ts b/apps/www/src/app/api/v1/runs/route.ts
new file mode 100644
index 0000000000..8c23b1651d
--- /dev/null
+++ b/apps/www/src/app/api/v1/runs/route.ts
@@ -0,0 +1,7 @@
+import { dataClient } from "@/lib/data";
+
+/** Every published run, by date, oldest first. */
+export const GET = async () => {
+ const { latest, dates } = await dataClient().getIndex();
+ return Response.json({ latest, dates });
+};
diff --git a/apps/www/src/app/apple-icon.png b/apps/www/src/app/apple-icon.png
new file mode 100644
index 0000000000..7b2abb9dd1
Binary files /dev/null and b/apps/www/src/app/apple-icon.png differ
diff --git a/apps/www/src/app/favicon.ico b/apps/www/src/app/favicon.ico
new file mode 100644
index 0000000000..38ec2907c1
Binary files /dev/null and b/apps/www/src/app/favicon.ico differ
diff --git a/apps/www/src/app/globals.css b/apps/www/src/app/globals.css
new file mode 100644
index 0000000000..cafa54dd4b
--- /dev/null
+++ b/apps/www/src/app/globals.css
@@ -0,0 +1,504 @@
+@import "tailwindcss";
+/* As the vlt docs import them: the animate-in/out utilities and shadcn's variants. */
+@import "tw-animate-css";
+@import "./shadcn.css";
+/* The docs' type system (vltpkg/www/docs/src/styles/typeset.css), verbatim. */
+@import "./typeset.css";
+@import "./prose.css";
+
+/*
+ * Design system.
+ *
+ * The neutral palette is PORTED from the vltpkg docs theme
+ * (vltpkg/www/docs/src/styles/theme.css, read-only) so the two sites share
+ * tokens rather than resemble each other. Ported, not re-derived: a second
+ * hand-written copy of an oklch ramp is a second thing to drift.
+ *
+ * Dark mode switches on [data-theme="dark"], which is the docs' own variant.
+ * next-themes is configured with attribute="data-theme" to match. The default
+ * (a `class`) would leave every dark rule present and never applied — styles
+ * that exist and never arrive, which is this repository's signature failure.
+ */
+
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+
+/* The snapshot banner is in the header while a past run is shown. */
+:root:has([data-snapshot-banner]) {
+ --banner-height: 2.5rem;
+}
+
+/* --- ported neutral tokens ------------------------------------------- */
+:root {
+ /* The docs site's navbar height, from vltpkg's sidebar-provider. Under it, on
+ a past run, the snapshot banner (components/snapshot-banner.tsx); the
+ header is both, and everything placed below the header uses the sum. */
+ --nav-height: 3.5rem;
+ --banner-height: 0px;
+ --header-height: calc(var(--nav-height) + var(--banner-height));
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.205 0 0);
+ --card: oklch(0.985 0 0);
+ --card-foreground: oklch(0.205 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.205 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.96 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.96 0 0);
+ --muted-foreground: oklch(0.5 0 0);
+ --accent: oklch(0.96 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.925 0 0);
+ --input: oklch(0.925 0 0);
+ --ring: oklch(0.708 0 0);
+ --radius: 0.5rem;
+
+ /*
+ * The docs' code-card tokens, ported for the figure container: the inner
+ * panel's fill and the shell's hairline ring. Same values, both schemes.
+ */
+ --illustration: oklch(1 0 0);
+ --border-illustration: oklch(0 0 0 / 8%);
+
+ /* The docs' sidebar tokens: it shares the page background. */
+ --sidebar: var(--background);
+ --sidebar-foreground: var(--foreground);
+ --sidebar-primary: var(--primary);
+ --sidebar-primary-foreground: var(--primary-foreground);
+ --sidebar-accent: oklch(0.955 0 0);
+ --sidebar-accent-foreground: var(--foreground);
+ --sidebar-border: var(--border);
+ --sidebar-ring: var(--ring);
+
+ /* Steps the docs theme does not carry, derived from its own ramp: table row rules, and
+ the hero's blueprint lines. */
+ --line-soft: oklch(0.955 0 0);
+ --ink-faint: oklch(0.65 0 0);
+
+ /*
+ * The BRAND accent, deliberately not named `--accent`.
+ *
+ * In a shadcn token set `accent` is the subtle hover SURFACE, not a brand
+ * colour. Mapping an orange onto it would turn every component's hover
+ * state orange.
+ */
+ --brand: oklch(0.58 0.2 32);
+
+ /* Semantic, used sparingly. */
+ --better: oklch(0.52 0.13 155);
+ --worse: oklch(0.52 0.18 25);
+ --absent: oklch(0.55 0.12 75);
+
+ --tool-bun: #d9408f;
+ --tool-vlt: #000000;
+ --tool-zpm: #5a6ff0;
+ --tool-upm: #0e8c8c;
+ --tool-aube: #c2410c;
+ --tool-pacquet: #8a5a0a;
+ --tool-pnpm: #b87f00;
+ --tool-deno: #12925c;
+ --tool-berry: #9555bb;
+ --tool-yarn: #117cad;
+ /*
+ * BRAND colours: each tool is drawn in its own, everywhere it is drawn --
+ * lines, bars, dots, swatches. The source is the previous app's map
+ * (suite/scripts/generate-chart.js, COLORS and REGISTRY_COLORS) and the
+ * marks in components/icons. vlt is black here and white on dark.
+ *
+ * Where a brand colour could not be used as it stands:
+ *
+ * too light for a 3:1 mark on white pnpm, aws (yellows), deno (mint),
+ * bun (pink), jfrog, node -- darkened
+ * on light only; dark keeps the brand
+ * two brands, one colour aube was the same amber as pnpm and
+ * is a burnt orange; upm was a green
+ * beside deno's and is a teal; deno
+ * keeps the mint over its own black,
+ * which is vlt's
+ *
+ * Every value clears 3:1 against its page background, measured. The set is
+ * NOT mutually distinguishable and cannot be -- see FRONTEND.md -- so no
+ * figure relies on colour alone: every line, bar and dot is also named.
+ */
+ --tool-npm: #cb0606;
+ --tool-node: #4a8f3a;
+ --tool-nx: #2563eb;
+ --tool-turbo: #e0113f;
+ --tool-vp: #7e14ff;
+ --tool-pnpm11: #a85a00;
+ --tool-aws: #c77700;
+ --tool-cloudsmith: #2a6fe1;
+ --tool-github: #8250df;
+ --tool-jfrog: #2a9a30;
+ /* The consumed names, emitted here rather than mapped through
+ @theme: `inline` does not stop Tailwind tree-shaking an entry no
+ utility references. Measured — 1 of 20 survived the mapping. */
+ --color-tool-bun: var(--tool-bun);
+ --color-tool-vlt: var(--tool-vlt);
+ --color-tool-zpm: var(--tool-zpm);
+ --color-tool-upm: var(--tool-upm);
+ --color-tool-aube: var(--tool-aube);
+ --color-tool-pacquet: var(--tool-pacquet);
+ --color-tool-pnpm: var(--tool-pnpm);
+ --color-tool-deno: var(--tool-deno);
+ --color-tool-berry: var(--tool-berry);
+ --color-tool-yarn: var(--tool-yarn);
+ --color-tool-npm: var(--tool-npm);
+ --color-tool-node: var(--tool-node);
+ --color-tool-nx: var(--tool-nx);
+ --color-tool-turbo: var(--tool-turbo);
+ --color-tool-vp: var(--tool-vp);
+ --color-tool-pnpm11: var(--tool-pnpm11);
+ --color-tool-aws: var(--tool-aws);
+ --color-tool-cloudsmith: var(--tool-cloudsmith);
+ --color-tool-github: var(--tool-github);
+ --color-tool-jfrog: var(--tool-jfrog);
+}
+
+[data-theme="dark"] {
+ --background: oklch(0 0 0);
+ --foreground: oklch(0.945 0 0);
+ --card: oklch(0.145 0 0);
+ --card-foreground: oklch(0.945 0 0);
+ --popover: oklch(0.145 0 0);
+ --popover-foreground: oklch(0.945 0 0);
+ --primary: oklch(0.945 0 0);
+ --primary-foreground: oklch(0.145 0 0);
+ --secondary: oklch(0.235 0 0);
+ --secondary-foreground: oklch(0.945 0 0);
+ --muted: oklch(0.2 0 0);
+ --muted-foreground: oklch(0.715 0 0);
+ --accent: oklch(0.235 0 0);
+ --accent-foreground: oklch(0.945 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 12%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+
+ --illustration: oklch(0.1 0 0);
+ --border-illustration: oklch(1 0 0 / 8%);
+ --sidebar-accent: oklch(0.235 0 0);
+
+ --line-soft: oklch(1 0 0 / 7%);
+ --ink-faint: oklch(0.55 0 0);
+ --brand: oklch(0.72 0.17 36);
+ --better: oklch(0.78 0.16 155);
+ --worse: oklch(0.72 0.17 25);
+ --absent: oklch(0.8 0.13 80);
+
+ --tool-bun: #f472b6;
+ --tool-vlt: #ffffff;
+ --tool-zpm: #8c9dff;
+ --tool-upm: #2fd4c8;
+ --tool-aube: #ff9f6b;
+ --tool-pacquet: #d9a441;
+ --tool-pnpm: #f9ad00;
+ --tool-deno: #70ffaf;
+ --tool-berry: #b98ae0;
+ --tool-yarn: #4fb3e0;
+ --tool-npm: #ff5a4f;
+ --tool-node: #84ba64;
+ --tool-nx: #60a5fa;
+ --tool-turbo: #ff4d79;
+ --tool-vp: #a66bff;
+ --tool-pnpm11: #e68a00;
+ --tool-aws: #ff9900;
+ --tool-cloudsmith: #6aa0f5;
+ --tool-github: #a57cf0;
+ --tool-jfrog: #40be46;
+ /* The consumed names, emitted here rather than mapped through
+ @theme: `inline` does not stop Tailwind tree-shaking an entry no
+ utility references. Measured — 1 of 20 survived the mapping. */
+ --color-tool-bun: var(--tool-bun);
+ --color-tool-vlt: var(--tool-vlt);
+ --color-tool-zpm: var(--tool-zpm);
+ --color-tool-upm: var(--tool-upm);
+ --color-tool-aube: var(--tool-aube);
+ --color-tool-pacquet: var(--tool-pacquet);
+ --color-tool-pnpm: var(--tool-pnpm);
+ --color-tool-deno: var(--tool-deno);
+ --color-tool-berry: var(--tool-berry);
+ --color-tool-yarn: var(--tool-yarn);
+ --color-tool-npm: var(--tool-npm);
+ --color-tool-node: var(--tool-node);
+ --color-tool-nx: var(--tool-nx);
+ --color-tool-turbo: var(--tool-turbo);
+ --color-tool-vp: var(--tool-vp);
+ --color-tool-pnpm11: var(--tool-pnpm11);
+ --color-tool-aws: var(--tool-aws);
+ --color-tool-cloudsmith: var(--tool-cloudsmith);
+ --color-tool-github: var(--tool-github);
+ --color-tool-jfrog: var(--tool-jfrog);
+}
+
+/*
+ * Mapped with `@theme inline`, as the docs theme does.
+ *
+ * `inline` keeps the var() reference rather than resolving it at build time,
+ * which is what makes the [data-theme] switch reach a utility class at all.
+ * It also sidesteps the tree-shaking that silently emptied the tool ramp when
+ * these lived in a plain `@theme` and no utility referenced them.
+ */
+@theme inline {
+ --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+ --font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular", monospace;
+ /* The docs' third face, for the sidebar's labels and the breadcrumbs. */
+ --font-pixel: var(--font-geist-pixel);
+ /* The docs' app-wide body size: `text-body` on , and typeset reads it too. */
+ --text-body: 0.9375rem;
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-illustration: var(--illustration);
+ --color-border-illustration: var(--border-illustration);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ /* This app's semantic names, mapped onto the ported ramp. */
+ --color-line-soft: var(--line-soft);
+ --color-ink: var(--foreground);
+ --color-ink-soft: var(--muted-foreground);
+ --color-ink-faint: var(--ink-faint);
+ --color-brand: var(--brand);
+ --color-better: var(--better);
+ --color-worse: var(--worse);
+ --color-absent: var(--absent);
+}
+
+/* The docs' article preset (vltpkg/www/docs/src/styles/theme.css), verbatim; unlayered so it
+ overrides typeset.css's layered defaults. */
+.typeset-docs {
+ --typeset-font-body: var(--font-sans);
+ --typeset-font-heading: var(--font-sans);
+ --typeset-font-mono: var(--font-mono);
+ --typeset-size: var(--text-body);
+ --typeset-leading: 1.75;
+ --typeset-flow: 1.25em;
+}
+
+@layer base {
+ /*
+ * Scrollbars: a slim thumb and no rail, everywhere -- the page and every
+ * inner scroller (sidebar, menus, tables). Chrome and Safari take the
+ * ::-webkit-scrollbar rules, which allow a 6px bar (a 4px thumb inside a
+ * transparent edge). Chrome ignores those rules wherever the standard
+ * properties are set, and the standard `thin` is wider, so the standard
+ * properties are given to Firefox alone, whose `thin` is as slim as it goes.
+ */
+ @supports (-moz-appearance: none) {
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent) transparent;
+ }
+ }
+ ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ background: transparent;
+ }
+ ::-webkit-scrollbar-track,
+ ::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ border: 1px solid transparent;
+ border-radius: 9999px;
+ background: color-mix(in oklab, var(--foreground) 22%, transparent) padding-box;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in oklab, var(--foreground) 40%, transparent) padding-box;
+ }
+ /*
+ * The scrollbar's width while the search palette or a menu locks the page's
+ * scroll (Radix sets the variable on body then), and 0 otherwise. The lock
+ * takes the scrollbar away and widens the viewport; the body is given a
+ * margin for it, but what is fixed to the viewport (the floating breadcrumb
+ * bar, the outline, back to top) sits that much further in by this, so
+ * nothing moves.
+ */
+ body {
+ --lock-gap: var(--removed-body-scroll-bar-size, 0px);
+ }
+
+ /*
+ * shadcn/ui components use a bare `border` class, which in Tailwind 4 sets
+ * the WIDTH only and leaves the colour at `currentColor`. Measured in the
+ * browser before this rule existed: the dropdown menu drew a near-black
+ * 1px outline, lab(7.78 0 0) -- the foreground ink, not a border colour.
+ *
+ * shadcn's own globals.css carries `* { @apply border-border }` for exactly
+ * this reason. Ours sets the property directly. Utilities still win, so the
+ * explicit border colours in this app's own components
+ * (`border-line-soft`, the brand outlines) are unaffected, as are inline `borderColor` style props.
+ *
+ * shadcn also ships `outline-ring/50` in the same rule. Not adopted: the
+ * focus ring below is the full-strength ring colour, which is more visible
+ * than a half-transparent one.
+ */
+ *,
+ ::before,
+ ::after {
+ border-color: var(--border);
+ }
+
+ html {
+ background: var(--background);
+ }
+
+ body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .tabular,
+ table {
+ font-variant-numeric: tabular-nums;
+ }
+
+ /*
+ * Selection and focus are the docs site's: a translucent blue that keeps
+ * the selected text's own colour, and a neutral ring. Both were the brand
+ * orange, which made the one accent on the page mean three things.
+ */
+ ::selection {
+ background: oklch(0.62 0.19 255 / 0.25);
+ }
+
+ [data-theme="dark"] ::selection {
+ background: oklch(0.62 0.19 255 / 0.45);
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+ }
+}
+
+/* The docs' page scroll fade (vltpkg/www/docs/src/styles/prose.css), verbatim.
+ The page is the scroller, so shadcn's scroll-fade (a mask on its own
+ scroller) cannot reach it. Instead background-coloured edges pin under the
+ navbar and to the viewport's bottom, inside the content column so they
+ start where the sidebar ends, fading in once the page leaves the top and
+ out as it reaches the end. */
+@supports (animation-timeline: scroll()) {
+ .page-fade {
+ position: sticky;
+ z-index: 30;
+ height: 0;
+ pointer-events: none;
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset-inline: 0;
+ height: --spacing(8);
+ opacity: 0; /* inactive timeline (page doesn't scroll) skips the animation, so stay hidden */
+ animation: page-fade linear both;
+ animation-timeline: scroll(root);
+ }
+ }
+
+ .page-fade-top {
+ top: var(--header-height);
+
+ &::after {
+ top: 0;
+ background: linear-gradient(var(--background), transparent);
+ animation-range: 0 --spacing(24);
+ }
+ }
+
+ .page-fade-bottom {
+ bottom: 0;
+
+ &::after {
+ bottom: 0;
+ background: linear-gradient(transparent, var(--background));
+ animation-direction: reverse;
+ animation-range: calc(100% - --spacing(24)) 100%;
+ }
+ }
+}
+
+@keyframes page-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+/*
+ * A report the server is re-rendering (components/report-pending.tsx): what is
+ * stale dims, and each figure on it shimmers, so the page reads as updating
+ * without going blank. The dim waits a beat, so a fast answer never flashes.
+ * A tools change leaves the trend (`data-live`) alone: it has already redrawn.
+ */
+.pending-region,
+.pending-region figure {
+ transition: opacity 150ms ease-out;
+}
+
+.pending-region[data-pending="data"],
+.pending-region[data-pending="tools"] figure:not([data-live]) {
+ opacity: 0.6;
+ transition-delay: 100ms;
+}
+
+.pending-region[data-pending] figure:not([data-live]) [data-shimmer]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: linear-gradient(
+ 100deg,
+ transparent 35%,
+ color-mix(in oklab, var(--foreground) 6%, transparent) 50%,
+ transparent 65%
+ )
+ 0 0 / 300% 100%;
+ animation: pending-shimmer 1.6s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pending-region[data-pending] figure:not([data-live]) [data-shimmer]::after {
+ animation: none;
+ background: color-mix(in oklab, var(--foreground) 4%, transparent);
+ }
+}
+
+@keyframes pending-shimmer {
+ from {
+ background-position: 100% 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
diff --git a/apps/www/src/app/icon0.svg b/apps/www/src/app/icon0.svg
new file mode 100644
index 0000000000..ab2a6fbbe6
--- /dev/null
+++ b/apps/www/src/app/icon0.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/apps/www/src/app/icon1.png b/apps/www/src/app/icon1.png
new file mode 100644
index 0000000000..6de2aff42f
Binary files /dev/null and b/apps/www/src/app/icon1.png differ
diff --git a/apps/www/src/app/layout.tsx b/apps/www/src/app/layout.tsx
new file mode 100644
index 0000000000..6503b5dec7
--- /dev/null
+++ b/apps/www/src/app/layout.tsx
@@ -0,0 +1,106 @@
+import type { Metadata } from "next";
+import { Geist_Mono, Geist_Pixel, Inter } from "next/font/google";
+import { NuqsAdapter } from "nuqs/adapters/next/app";
+import type { CSSProperties, ReactNode } from "react";
+import "./globals.css";
+import { AppSidebar } from "@/components/app-sidebar";
+import { Navbar } from "@/components/navbar";
+import { ScrollToTop } from "@/components/scroll-to-top";
+import { SiteFooter } from "@/components/site-footer";
+import { ThemeProvider } from "@/components/theme-provider";
+import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { loadCatalog } from "@/lib/catalog";
+import { dataClient } from "@/lib/data";
+import { rootMetadata } from "@/lib/metadata";
+import { treeFor } from "@/lib/page-tree";
+
+/*
+ * The vlt docs site's three faces (vltpkg/www/docs/src/lib/fonts.ts).
+ *
+ * Inter sets everything that is read, headings included -- emphasis comes from
+ * weight and tracking, not from a second family. Geist Mono carries every
+ * label, column head and number, which is what makes the data read as data.
+ * Geist Pixel is the docs' sidebar labels and breadcrumbs, and only those.
+ */
+const sans = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+ display: "swap",
+});
+
+const mono = Geist_Mono({
+ subsets: ["latin"],
+ variable: "--font-geist-mono",
+ display: "swap",
+});
+
+const pixel = Geist_Pixel({
+ subsets: ["latin"],
+ variable: "--font-geist-pixel",
+ // next has no metrics for this face, so an explicit fallback stops turbopack trying to size one
+ fallback: ["monospace"],
+});
+
+/* The title template and site name, shared with every page: `lib/metadata.ts`.
+ The icons are files in this directory, vlt.io's own, found by Next. */
+export const metadata: Metadata = rootMetadata;
+
+/*
+ * The docs' shell (vltpkg/www/docs/src/app/layout.tsx and its providers): the
+ * sidebar provider as a column -- the navbar on top, then the row of sidebar
+ * and content on the docs' inset, then the footer, full width under both.
+ *
+ * All of it is the layout's, so a navigation re-renders the page and nothing
+ * else: the sidebar keeps its scroll and its search. Its data
+ * is the latest run's catalog, fetched here once per full load. Changed from
+ * the docs: the sidebar is `sticky` in its row rather than `fixed`, so it stops
+ * where the footer starts; the inset has `min-w-0`, so a wide chart shrinks to
+ * the column instead of pushing it; and its top padding is the sidebar
+ * header's (`pt-4`), so a page's first row -- breadcrumbs and filters -- lines
+ * up with the search button across the page. The content sits on `max-w-6xl`.
+ */
+const RootLayout = async ({ children }: { children: ReactNode }) => {
+ const catalog = await loadCatalog(dataClient());
+ const tree = treeFor(catalog.scenarios);
+ return (
+
+
+
+ {/* nuqs: every query parameter is defined in lib/params.ts. */}
+
+
+
+
+
+
+
+
+
{children}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RootLayout;
diff --git a/apps/www/src/app/llms-full.txt/route.ts b/apps/www/src/app/llms-full.txt/route.ts
new file mode 100644
index 0000000000..b6b3ef78fe
--- /dev/null
+++ b/apps/www/src/app/llms-full.txt/route.ts
@@ -0,0 +1,26 @@
+import { loadCatalog } from "@/lib/catalog";
+import { dataClient } from "@/lib/data";
+import { reportMarkdown } from "@/lib/markdown";
+import { siteOrigin } from "@/lib/metadata";
+import { loadReportParams } from "@/lib/params";
+import { loadReport } from "@/lib/report";
+import { withAverage } from "@/lib/scenarios";
+import { SECTIONS } from "@/lib/sections";
+
+/** Every scenario of the latest run, at the default view, one after another. */
+export const GET = async (request: Request) => {
+ const client = dataClient();
+ const { scenarios } = await loadCatalog(client);
+ const query = loadReportParams(new URLSearchParams());
+ const texts = await Promise.all(
+ SECTIONS.flatMap((section) =>
+ withAverage(scenarios[section]).map(async ({ variation }) => {
+ const result = await loadReport({ section, scenario: variation, query, client });
+ return result.status === "ok" ? reportMarkdown(result.report, siteOrigin(request)) : "";
+ }),
+ ),
+ );
+ return new Response(texts.filter(Boolean).join("\n\n"), {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+};
diff --git a/apps/www/src/app/llms.mdx/[[...slug]]/route.ts b/apps/www/src/app/llms.mdx/[[...slug]]/route.ts
new file mode 100644
index 0000000000..f25c82e2ef
--- /dev/null
+++ b/apps/www/src/app/llms.mdx/[[...slug]]/route.ts
@@ -0,0 +1,77 @@
+import { GET as llmsTxt } from "@/app/llms.txt/route";
+import { loadCatalog } from "@/lib/catalog";
+import { dataClient } from "@/lib/data";
+import { methodologyMarkdown } from "@/app/methodology/markdown";
+import { reportMarkdown } from "@/lib/markdown";
+import { siteOrigin } from "@/lib/metadata";
+import { loadReportParams } from "@/lib/params";
+import { ignoredSelection, loadReport } from "@/lib/report";
+import { SECTION_DEFS } from "@/lib/sections";
+
+// `Vary` because the same url serves html to browsers
+const markdown = (body: string, status = 200) =>
+ new Response(body, {
+ status,
+ headers: { "Content-Type": "text/markdown; charset=utf-8", Vary: "Accept" },
+ });
+
+// reached through the `Accept: text/markdown` and `/:path+.md` rewrites in next.config.ts
+export const GET = async (
+ request: Request,
+ { params }: { params: Promise<{ slug?: string[] }> },
+) => {
+ const { slug = [] } = await params;
+ const origin = siteOrigin(request);
+ const abs = (path: string) => new URL(path, origin).href;
+ // the About page (once the landing page) is cards and links, so its markdown is the llms.txt index
+ // `/about` is that page now; `/` redirects to the default benchmark
+ if (slug.length === 0 || slug.join("/") === "about") {
+ return markdown(await (await llmsTxt(request)).text());
+ }
+ if (slug.join("/") === "methodology") {
+ return markdown(methodologyMarkdown((await loadCatalog(dataClient())).scenarios, origin));
+ }
+
+ const path = `/${slug.join("/")}`;
+ // a body agents can act on; `notFound()` would send an empty one
+ const missing = (detail = "") =>
+ markdown(
+ `# 404: Page not found\n\nThere is no page at \`${path}\` on vlt benchmarks.${detail}\n\n- [llms.txt](${abs("/llms.txt")}) lists every page, each available as Markdown\n- [openapi.json](${abs("/openapi.json")}) describes the JSON API\n- [Home](${abs("/")})\n`,
+ 404,
+ );
+
+ const [section = "", scenario, ...extra] = slug.map(decodeURIComponent);
+ if (extra.length > 0) return missing();
+ const query = loadReportParams(new URL(request.url).searchParams);
+ let result = await loadReport({ section, scenario, query });
+ // `/{section}` opens on its average (or first scenario), as the page does
+ if (result.status === "unknown-scenario" && scenario === undefined) {
+ result = await loadReport({ section, scenario: result.opens, query });
+ }
+
+ switch (result.status) {
+ case "unknown-section":
+ return missing();
+ case "unknown-scenario":
+ return missing(
+ ` Scenarios ${SECTION_DEFS[result.section].label} published in this run: ${result.scenarios.map((s) => `\`${s}\``).join(", ")}.`,
+ );
+ case "no-dates":
+ return markdown(
+ `# ${SECTION_DEFS[result.section].label}\n\nThe served tree reports no dates. There is nothing to show.\n`,
+ );
+ case "no-results":
+ return markdown(
+ `# ${SECTION_DEFS[result.section].label}\n\nNo ${SECTION_DEFS[result.section].label.toLowerCase()} results in the run of ${result.date}.${result.availableDates.length === 0 ? "" : ` Recent runs that did publish them: ${result.availableDates.map((d) => `[${d}](${abs(`/${result.section}.md?date=${d}`)})`).join(", ")}.`}\n`,
+ );
+ case "ok": {
+ const ignored = ignoredSelection(query, result.report);
+ const body = reportMarkdown(result.report, origin);
+ return markdown(
+ ignored === null
+ ? body
+ : `> ${ignored} This is the report without that selection.\n\n${body}`,
+ );
+ }
+ }
+};
diff --git a/apps/www/src/app/llms.txt/route.ts b/apps/www/src/app/llms.txt/route.ts
new file mode 100644
index 0000000000..0f8d3b2cc5
--- /dev/null
+++ b/apps/www/src/app/llms.txt/route.ts
@@ -0,0 +1,43 @@
+import { loadCatalog } from "@/lib/catalog";
+import { dataClient } from "@/lib/data";
+import { READING, SITE_DESCRIPTION, SITE_NAME, siteOrigin } from "@/lib/metadata";
+import { DESCRIPTIONS, scenarioPath, withAverage } from "@/lib/scenarios";
+import { SECTION_DEFS, SECTIONS } from "@/lib/sections";
+
+/** The llms.txt index, in the docs' shape: a title, a summary, how to read the results, then every page. */
+export const GET = async (request: Request) => {
+ const abs = (path: string) => new URL(path, siteOrigin(request)).href;
+ const { scenarios } = await loadCatalog(dataClient());
+ const text = `# ${SITE_NAME}
+
+> ${SITE_DESCRIPTION}
+
+Every page is available as Markdown: append \`.md\` to its URL, or request the page with an \`Accept: text/markdown\` header. The links below point at the Markdown versions. Query parameters apply to the Markdown as they do to the page: ${abs("/package-managers/clean.md?fixture=babylon")} is that scenario on one fixture. [llms-full.txt](${abs("/llms-full.txt")}) holds every scenario of the latest run, at the default view, in one file. [Methodology](${abs("/methodology.md")}) holds the method, the runner environment and what each scenario measures. [About](${abs("/about.md")}) is this index as a page; \`/\` itself opens on the registries average, and each benchmark's own URL on its average: the derived mean of its scenarios.
+
+## Reading the results
+
+${READING.map((line) => `- ${line}`).join("\n")}
+- Page parameters: \`date\`, \`fixture\`, \`range\` (30, 60, 90 or 120 days of trend), \`from\` and \`to\` (an exact trend window), \`hide\` (comma-separated tools), \`metric\` (total, change, baseline or per-package), \`exclude\` (\`YYYY-MM-DD\` or \`tool@YYYY-MM-DD\`, comma-separated).
+
+## API and MCP
+
+- JSON API, described at [openapi.json](${abs("/openapi.json")}): \`/api/v1/runs\`, \`/api/v1/catalog\`, \`/api/v1/{section}/{scenario}\` (the full report, same parameters as the page) and \`/api/v1/{section}/{scenario}/history\` (the trend, failures and coverage).
+- MCP server (streamable HTTP, read-only, no auth): ${abs("/mcp")}. Tools: \`list_benchmarks\`, \`get_results\`, \`get_history\`, \`compare_scenarios\`. Add it to Claude Code with \`claude mcp add --transport http vlt-benchmarks ${abs("/mcp")}\`.
+
+## Benchmarks
+${SECTIONS.map(
+ (section) => `
+### ${SECTION_DEFS[section].label}
+
+${SECTION_DEFS[section].about}
+
+${withAverage(scenarios[section])
+ .map(
+ (s) =>
+ `- [${s.variation}](${abs(`${scenarioPath(section, s.variation)}.md`)}): ${DESCRIPTIONS[s.variation] ?? "Published, with no description yet."}`,
+ )
+ .join("\n")}`,
+).join("\n")}
+`;
+ return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
+};
diff --git a/apps/www/src/app/mcp/route.ts b/apps/www/src/app/mcp/route.ts
new file mode 100644
index 0000000000..12fa766c55
--- /dev/null
+++ b/apps/www/src/app/mcp/route.ts
@@ -0,0 +1,12 @@
+import { createMcpHandler } from "mcp-handler";
+import { registerTools } from "@/lib/mcp";
+import { SITE_NAME, siteOrigin } from "@/lib/metadata";
+
+/* Stateless streamable HTTP, so a handler per request costs nothing and lets
+ the tools write links against the origin that was asked. */
+const handler = (request: Request) =>
+ createMcpHandler((server) => registerTools(server, siteOrigin(request)), {
+ serverInfo: { name: SITE_NAME, version: "1.0.0" },
+ })(request);
+
+export { handler as GET, handler as POST, handler as DELETE };
diff --git a/apps/www/src/app/methodology/components.tsx b/apps/www/src/app/methodology/components.tsx
new file mode 100644
index 0000000000..a6d6c571aa
--- /dev/null
+++ b/apps/www/src/app/methodology/components.tsx
@@ -0,0 +1,95 @@
+import Link from "next/link";
+import { Heading } from "@/components/heading";
+import { InlineCode, Marked } from "@/components/inline-code";
+import type { SectionScenarios } from "@/lib/page-tree";
+import { METHOD, READING, RESOURCES, RUNNER, RUNNER_NOTE, TRADEMARK } from "@/lib/metadata";
+import { DESCRIPTIONS, scenarioPath, stateParts } from "@/lib/scenarios";
+import { SECTION_DEFS, SECTIONS } from "@/lib/sections";
+
+/*
+ * The Methodology page's blocks, as content.mdx places them. The wording is the
+ * fact-checked constants in lib/metadata.ts, which the report and llms.txt
+ * twins share; each block's Markdown is in ./markdown.ts.
+ */
+
+export const Method = () =>
{METHOD}
;
+
+export const Environment = () => (
+ <>
+
+ {RUNNER.map(([term, value]) => (
+
+
{term}
+
+ {value}
+
+
+ ))}
+
+
{RUNNER_NOTE}
+ >
+);
+
+export const Reading = () => (
+
+ {READING.map((line) => (
+
+
+
+ ))}
+
+);
+
+/** Each published scenario, by benchmark, with what was on disk before it ran. */
+export const Scenarios = ({ scenarios }: { scenarios: SectionScenarios }) =>
+ SECTIONS.map((section) => {
+ const label = SECTION_DEFS[section].label;
+ return (
+
+ {label}
+
{SECTION_DEFS[section].about}
+
+ {scenarios[section].map((s) => (
+
+
+
+ {s.variation}
+
+
+
+ {DESCRIPTIONS[s.variation] ?? "Published, with no description yet."}
+ {s.kind === "other" ? null : (
+
+ );
+};
diff --git a/apps/www/src/components/app-sidebar.tsx b/apps/www/src/components/app-sidebar.tsx
new file mode 100644
index 0000000000..ffc391f466
--- /dev/null
+++ b/apps/www/src/components/app-sidebar.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { useRef, useState } from "react";
+import Link, { useLinkStatus } from "next/link";
+import { SidebarCite } from "@/components/cite";
+import { Bluesky } from "@/components/icons/bluesky";
+import { Discord } from "@/components/icons/discord";
+import { Github } from "@/components/icons/github";
+import { Linkedin } from "@/components/icons/linkedin";
+import { TwitterX } from "@/components/icons/twitterx";
+import { Rule } from "@/components/rule";
+import { SearchDialog, SearchTrigger } from "@/components/search";
+import type { SuggestionGroup } from "@/components/search";
+import { ThemeSwitcher } from "@/components/theme-switcher";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import type * as PageTree from "@/lib/page-tree";
+import { WithLocation } from "@/components/with-location";
+import { cn } from "@/lib/utils";
+
+/*
+ * The vlt docs' AppSidebar (vltpkg/www/docs/src/components/app-sidebar.tsx):
+ * its header (search), its root view's groups -- the etched `Rule` between
+ * them, the pixel-face labels, the rows and their current-page state -- and
+ * its footer (socials, theme). What differs:
+ *
+ * - no drill-in. There are three benchmarks, so all three are open at the
+ * root, always: each a group headed by a link to the benchmark's own page,
+ * set as the docs set a section's title, then its scenarios. Package
+ * managers' sit under Cold / Warm / CI captions -- small, muted, sans, so
+ * they read as labels rather than links -- each run of pages indented on a
+ * hairline guide, as a nested menu. No back arrow, nothing collapses.
+ * - the Methodology page is a loose page at the root, above the benchmarks,
+ * as the docs' root lists loose pages before a `Rule`
+ * - the tree is `lib/page-tree.ts`'s, built from what the page's date
+ * published, not fumadocs' from MDX
+ * - every link carries the page's query (fixture, hidden tools, window, ...),
+ * as the old scenario picker's did; a value another scenario does not hold
+ * is ignored there
+ * - it is the root layout's, so it never remounts: its data is the latest
+ * run's catalog (`lib/catalog.ts`), and the current page comes from the
+ * address, not from the page
+ * - above the footer, a card citing the page (`SidebarCite`)
+ * - the sidebar keeps its right border (the docs pass `border-none`), and the
+ * socials' GitHub link points at this repository
+ */
+const decode = (path: string) => {
+ try {
+ return decodeURIComponent(path);
+ } catch {
+ return path;
+ }
+};
+
+// paths in the tree are unencoded; `+` arrives as %2B from links that encoded it (`decode`)
+
+const row =
+ "h-9 rounded-lg px-3 text-sm text-sidebar-foreground/80 hover:text-sidebar-foreground aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium aria-[current=page]:text-sidebar-foreground";
+
+/* The row just clicked, while its page is on its way: a quiet dot at the row's end. */
+const Pending = () =>
+ useLinkStatus().pending ? (
+
+ ) : null;
+
+const PageLink = ({ page, className }: { page: PageTree.Item; className?: string }) => {
+ const { setOpenMobile } = useSidebar();
+ return (
+
+
+ {({ pathname, query }) => (
+
+ setOpenMobile(false)}
+ >
+ {page.name}
+
+
+
+ )}
+
+
+ );
+};
+
+/* A page, or a captioned run of them: a non-interactive caption, then its pages on a guide line. */
+const Entry = ({ node }: { node: PageTree.Node }) =>
+ node.type === "page" ? (
+
+ ) : node.type === "folder" ? (
+
+
+
+
+ >
+ );
+}
diff --git a/apps/www/src/components/blueprint.tsx b/apps/www/src/components/blueprint.tsx
new file mode 100644
index 0000000000..56c0bc7949
--- /dev/null
+++ b/apps/www/src/components/blueprint.tsx
@@ -0,0 +1,147 @@
+import type { ComponentType, SVGProps } from "react";
+import { Bun } from "@/components/icons/bun";
+import { Deno } from "@/components/icons/deno";
+import { Npm } from "@/components/icons/npm";
+import { Pnpm } from "@/components/icons/pnpm";
+import { Vlt } from "@/components/icons/vlt";
+
+type Icon = ComponentType & { size?: number }>;
+
+/** Back to front. `only` keeps a busy mark to the paths that carry its shape. */
+const MARKS: readonly { name: string; Icon: Icon; only?: string }[] = [
+ { name: "vlt", Icon: Vlt },
+ { name: "npm", Icon: Npm },
+ { name: "pnpm", Icon: Pnpm },
+ /* bun: the body, the eyes and the mouth; not the cheeks, glints or its double contour. */
+ {
+ name: "bun",
+ Icon: Bun,
+ only: "[&_path:not(:nth-child(1),:nth-child(4),:nth-child(6))]:hidden",
+ },
+ { name: "deno", Icon: Deno },
+];
+
+/* An isometric rack of slides: each panel stands upright facing the viewer's
+ left, and the row recedes up and to the left. One unit along the row moves
+ (K, 0.5) on screen; one unit across a panel moves (K, -0.5); up is up. */
+const K = 0.866;
+const SIDE = 30;
+const STEP = 21;
+const LOGO = 18;
+const INSET = (SIDE - LOGO) / 2;
+const THICK = 1;
+const REACH = 12;
+const X0 = 12;
+const Y0 = 19;
+const W = 122;
+const H = 100;
+
+const origin = (i: number): readonly [number, number] => [X0 + i * STEP * K, Y0 + i * STEP * 0.5];
+const face = (i: number): string => {
+ const [x, y] = origin(i);
+ return `matrix(${K} -0.5 0 1 ${x} ${y})`;
+};
+
+const OUTLINE =
+ "[&_path]:fill-none [&_path]:stroke-ink-soft [&_path]:[stroke-dasharray:3_3] [&_path]:[stroke-width:1px] [&_path]:[vector-effect:non-scaling-stroke]";
+const SOLID = "[&_path]:fill-ink [&_path]:stroke-none";
+
+/** The vlt mark's lower node, in the mark's own 24 units: where the docs'
+ 404 blueprint puts its construction circle. */
+const LENS = { cx: 12, cy: 17.9, r: 5 };
+
+/** The two rails the panels stand on, running past both ends of the row. */
+const rail = (across: number): string => {
+ const [x, y] = origin(0);
+ const [bx, by] = [x + K * across, y - 0.5 * across + SIDE];
+ const far = (MARKS.length - 1) * STEP + REACH;
+ return `M${bx - K * REACH} ${by - 0.5 * REACH}L${bx + K * far} ${by + 0.5 * far}`;
+};
+
+/**
+ * The package managers as a blueprint, after the vlt docs' 404 mark: each
+ * logo a dashed outline on a glass slide, the slides racked from the top left
+ * to the bottom right, and one construction circle drawn solid.
+ */
+export const Blueprint = ({ className = "" }: { className?: string }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {MARKS.map(({ name, Icon, only = "" }, i) => {
+ const [x, y] = origin(i);
+ return (
+
+ {/* The slide's far face, a hair behind: its thickness. */}
+
+
+
+
+
+
+
+ {name}
+
+
+ );
+ })}
+
+ {/* Drawn in the plane of the first slide, so it leans with the mark. */}
+
+
+
+
+
+
+
+);
diff --git a/apps/www/src/components/chart-legend.tsx b/apps/www/src/components/chart-legend.tsx
new file mode 100644
index 0000000000..1699a92a8c
--- /dev/null
+++ b/apps/www/src/components/chart-legend.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useHidden } from "@/components/tools-menu";
+import { ToolName } from "@/components/tool-name";
+import { toolColor } from "@/lib/chart-model";
+import type { SectionId } from "@/lib/sections";
+
+/**
+ * The legend: a bordered grid at the foot of the panel, on every chart that
+ * needs one.
+ *
+ * One cell per tool, hairlines between the cells in the panel's own border
+ * colour. In a cell: a marker in the tool's brand colour (dashed for the
+ * baseline, as its line or outline is), its mark and its name on the left,
+ * and its latest value on the right in mono -- inside one box, so the value
+ * plainly belongs to the name beside it.
+ *
+ * Each cell is a button that shows or hides its tool, through the same `hide`
+ * parameter the tools menu writes. A hidden tool's cell is recessed
+ * to the shell's shade, struck through, and says "hidden" where its value
+ * was.
+ *
+ * Two, three or five columns. The last row is filled out with empty cells so
+ * the grid is always a rectangle with every border drawn: with eleven tools
+ * in five columns that is four blanks, which reads as a table with empty
+ * cells rather than as a ragged edge.
+ *
+ * It is drawn with a 1px gap over a border-coloured ground rather than with
+ * borders on the cells, so there is no doubled line where two cells meet and
+ * none against the panel's own edge.
+ */
+export interface LegendEntry {
+ readonly tool: string;
+ readonly on: boolean;
+ /** The tool's latest value, already formatted; `null` when it has none. */
+ readonly value?: string | null;
+}
+
+/* How many blanks complete the last row at each column count. */
+const blanks = (count: number, columns: number): number => (columns - (count % columns)) % columns;
+
+export const ChartLegend = ({
+ entries,
+ section,
+ baseline,
+ onToggle,
+ onShowAll,
+}: {
+ entries: readonly LegendEntry[];
+ section: SectionId;
+ baseline: string | null;
+ onToggle: (tool: string) => void;
+ onShowAll: () => void;
+}) => {
+ /* Six or fewer (the registries) get three wide columns: five squeezed their long names. */
+ const wide = entries.length <= 6;
+ const fill = {
+ base: blanks(entries.length, 2),
+ sm: blanks(entries.length, 3),
+ lg: blanks(entries.length, wide ? 3 : 5),
+ };
+ const most = Math.max(fill.base, fill.sm, fill.lg);
+
+ return (
+
+ );
+};
+
+/**
+ * The legend on the figures by fixture: the trend's, wired to the same `hide`
+ * parameter the tools menu writes, so a tool hidden here is hidden everywhere.
+ */
+export const ToolLegend = ({
+ tools,
+ values = {},
+ section,
+ baseline,
+}: {
+ tools: readonly string[];
+ /** Each tool's value on what the figure shows, formatted. */
+ values?: { readonly [tool: string]: string | undefined };
+ section: SectionId;
+ baseline: string | null;
+}) => {
+ const { hidden, toggle, showAll } = useHidden(tools);
+ return (
+ ({
+ tool,
+ on: !hidden.includes(tool),
+ value: values[tool] ?? null,
+ }))}
+ onToggle={toggle}
+ onShowAll={showAll}
+ />
+ );
+};
diff --git a/apps/www/src/components/charts-echarts-dots.tsx b/apps/www/src/components/charts-echarts-dots.tsx
new file mode 100644
index 0000000000..976fb47b22
--- /dev/null
+++ b/apps/www/src/components/charts-echarts-dots.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import * as echarts from "echarts/core";
+import { LineChart as ELineChart } from "echarts/charts";
+import { GridComponent } from "echarts/components";
+import { SVGRenderer } from "echarts/renderers";
+import { useEffect, useRef } from "react";
+import { toolColor } from "@/lib/chart-model";
+
+echarts.use([ELineChart, GridComponent, SVGRenderer]);
+
+/**
+ * The sparkline, on ECharts, and the hook the hand-built figures share.
+ *
+ * EvilCharts ships no sparkline, so it is built directly on the same engine
+ * rather than bent out of a preset. The design wins over what a registry happens to provide
+ * (D1), and the engine is still ECharts, so the visual language stays one
+ * system.
+ *
+ * `SVGRenderer` only — registered here and passed explicitly. Canvas is not
+ * inspectable, not assertable and not reachable by assistive technology.
+ */
+
+export const useChart = (
+ build: (el: HTMLDivElement) => echarts.ECharts | null,
+ deps: readonly unknown[],
+) => {
+ const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (el === null) return;
+ const inst = build(el);
+ if (inst === null) return;
+ /* Its own box, not the window: a tab or a layout change resizes a chart too. */
+ const observer = new ResizeObserver(() => inst.resize());
+ observer.observe(el);
+ return () => {
+ observer.disconnect();
+ inst.dispose();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+ return ref;
+};
+
+export const SparklineECharts = ({
+ values,
+ tool,
+ width,
+ height = 24,
+ accent = false,
+}: {
+ /** One slot per published run; `null` where the tool has no usable value. */
+ values: readonly (number | null)[];
+ tool: string;
+ /** Omitted, it fills its container. */
+ width?: number;
+ height?: number;
+ /** Draw the last segment heavier, and the rest quieter: the latest movement. */
+ accent?: boolean;
+}) => {
+ const present = values.filter((v): v is number => typeof v === "number");
+ const color = toolColor(tool);
+
+ const ref = useChart(
+ (el) => {
+ if (present.length < 2) return null;
+ const inst = echarts.init(el, undefined, { renderer: "svg", width, height });
+ /* A line and a soft fill beneath it, and NO point markers: at this size
+ a dot per run is noise, and an end dot is a claim about one run. */
+ const line = {
+ type: "line",
+ /* Explicit, and the whole point: a null is a run with no value and
+ must break the line rather than be bridged. */
+ connectNulls: false,
+ showSymbol: false,
+ silent: true,
+ } as const;
+ const last = values.findLastIndex((v) => v !== null);
+ inst.setOption({
+ animation: false,
+ grid: { left: 1, right: 1, top: 3, bottom: 1 },
+ xAxis: { type: "category", show: false, boundaryGap: false, data: values.map((_, i) => i) },
+ yAxis: { type: "value", show: false, scale: true },
+ series: [
+ {
+ ...line,
+ data: values,
+ lineStyle: { width: accent ? 1 : 1.5, color, opacity: accent ? 0.5 : 1 },
+ areaStyle: { color, opacity: 0.12, origin: "start" },
+ },
+ ...(accent
+ ? [
+ {
+ ...line,
+ data: values.map((v, i) => (i >= last - 1 ? v : null)),
+ lineStyle: { width: 2, color },
+ },
+ ]
+ : []),
+ ],
+ });
+ return inst;
+ },
+ [values, tool, width, height, accent],
+ );
+
+ if (present.length < 2) {
+ return not enough runs;
+ }
+ return ;
+};
diff --git a/apps/www/src/components/charts-echarts.tsx b/apps/www/src/components/charts-echarts.tsx
new file mode 100644
index 0000000000..993ad441e8
--- /dev/null
+++ b/apps/www/src/components/charts-echarts.tsx
@@ -0,0 +1,1610 @@
+"use client";
+
+import * as echarts from "echarts/core";
+import {
+ ChevronDown,
+ CircleX,
+ Ruler,
+ SlidersHorizontal,
+ Tag,
+ TriangleAlert,
+ X,
+ ZoomOut,
+} from "lucide-react";
+import { useReducedMotion } from "motion/react";
+import { useTheme } from "next-themes";
+import { useQueryStates } from "nuqs";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PointerEvent as Pointer,
+ type ReactNode,
+} from "react";
+import { ChartLegend } from "@/components/chart-legend";
+import { PinnedRun, type PinnedNote, type TrendDetail } from "@/components/pinned-run";
+import { RangePicker } from "@/components/range-picker";
+import { usePending } from "@/components/report-pending";
+import { Callout, ChartFrame, Figure } from "@/components/report";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import type { ChartConfig } from "@/components/ui/echarts-chart";
+import { EChartsLineChart } from "@/components/ui/echarts-line-chart";
+import {
+ tooltipBaseOption,
+ tooltipIndicatorHtml,
+ tooltipRow,
+ tooltipShell,
+} from "@/components/ui/echarts-tooltip";
+import { toolColor } from "@/lib/chart-model";
+import { EVIL_AXIS_LABEL, EVIL_SPLIT_LINE, EVIL_TICK_DOT, escapeHtml } from "@/lib/chart-style";
+import {
+ bridges,
+ flagOutliers,
+ loneRuns,
+ metricRows,
+ nearestRun,
+ rollingMedian,
+ stepChange,
+ type Metric,
+} from "@/lib/figures";
+import { formatDuration } from "@/lib/findings";
+import { reportParams } from "@/lib/params";
+import type { SectionId } from "@/lib/sections";
+import { formatValueRange, parseValueRange } from "@/lib/value-range";
+import { versionNote, type DatedVersions, type VersionMarker } from "@/lib/version-markers";
+
+/**
+ * THE time-series chart: the report's "Performance over time". There was
+ * once a second, a look-alike built from raw ECharts options beside the stock
+ * EvilCharts one, and the two drifted apart; this is the only one.
+ *
+ * The plot is EvilCharts' vendored line chart (`ui/echarts-line-chart.tsx`),
+ * WHOLE, configured as its own example is
+ * (evilcharts.com/docs/echarts/line-chart/static) except for its resting
+ * dots: plain lines, a "border" dot on hover, clickable lines, the
+ * left-to-right draw-in. A dot at every run was a field of dots; the only
+ * resting marks are a solid dot on a run with no neighbour and a ring where
+ * a tool's version changed (see `lone` and `changed` below). Its grid, axes
+ * and tooltip surface are its own. The value axis has a title; the date axis
+ * does not need one.
+ *
+ * What this component owns around it:
+ *
+ * the frame one rounded border, the same container the tables use
+ * title (`Figure`); nothing is written under it
+ * controls the date range (presets and a calendar) and the scale,
+ * two outline menus in the container's top row beside the
+ * tab, through `range`/`from`/`to` and `scale`
+ * versions on the plot: a ring on a tool's line at the run where its
+ * version changed, and in the tooltip every tool's version
+ * on the hovered date, the changes written out
+ * the pin a click on the plot pins the nearest run (`at`): a rule,
+ * a dot on each line, and that run's values in the legend
+ * the brush EvilCharts' own, directly under the plot and on its x
+ * extent. Narrowing it and letting go makes its window the
+ * page's `from`/`to` (linked brushing, below)
+ * value zoom `y`: a vertical drag on the axis-label strip zooms the
+ * value axis to the band dragged (`lo~hi`, in the metric's
+ * units); Display > Value range > "Fit to shown tools"
+ * (`fit`) lets the axis leave zero, so hiding the slow tools
+ * zooms in. "Reset zoom" in the controls row undoes either;
+ * a new metric drops it. Marks off the zoomed axis are not
+ * drawn, and the axis title says "zoomed"
+ * the legend in the frame's footer: mark, colour, name, latest value,
+ * each entry toggling its tool through `hide`
+ *
+ * Props are serialisable data and config only. Data is fetched and shaped on
+ * the server; nothing honest is decided here.
+ *
+ * `renderer="svg"`. The vendored component defaults to CANVAS, and canvas is
+ * not inspectable, not assertable from a test, and not reachable by assistive
+ * technology.
+ *
+ * FOUR THINGS THE STOCK CHART CANNOT DO, and how each is handled without
+ * editing the vendored file:
+ *
+ * 1. A LOG AXIS, AND A ZOOMED ONE. It has neither option, and its
+ * `chartOptions` escape hatch is a shallow merge, so either means
+ * supplying a whole `yAxis`. The one supplied here copies the component's
+ * own axis styling value for value (`lib/chart-style.ts`, taken from its
+ * `buildMainAxes`) and changes only `type` -- for the plot and for the
+ * brush's miniature alike -- and, on the plot's axis only, its ends. It
+ * is supplied ONLY in log mode or when zoomed; an unzoomed linear axis is
+ * the stock axis, untouched.
+ * 2. A TOOLTIP THAT KNOWS THE UNIT. The stock one prints the raw number
+ * ("1.643"). The replacement is built from the component's own exported
+ * pieces -- its option base, shell, row and indicator -- so the surface is
+ * identical and only the value text differs.
+ * 3. REPAINTING ON A THEME CHANGE. It re-reads colours when the `class`
+ * attribute changes; this app switches `data-theme`. The chart is keyed on
+ * the resolved theme, so it remounts and re-reads. Handled here once, for
+ * every time-series chart.
+ * 4. A CLICK ON THE PLOT, AND A RULE THAT STAYS. It exposes neither its
+ * ECharts instance nor a reference line. The instance is looked up from
+ * its DOM node (`getInstanceByDom`) to hear clicks and to place the rule,
+ * which is an element of ours laid over the plot, as are the version
+ * rings and the pinned run's dots: as series symbols ECharts thins them
+ * to the labelled ticks whenever the axis is short of room.
+ *
+ * WHAT WAS GIVEN UP to keep this look, deliberately: labels at the ends of
+ * the lines, and axis text larger than the component's 10px. Neither can be
+ * added through `chartOptions` without replacing the series or axes the
+ * component builds. The legend carries each tool's latest value.
+ */
+const RENDERER = "svg" as const;
+
+/** One stroke weight for every tool. */
+const STROKE = 1.5;
+
+const PLOT = "h-[320px] w-full sm:h-[420px] lg:h-[520px]";
+
+/* A marker-only series beside a tool's line, for its runs with no neighbour. */
+const LONE = "__lone";
+/* A tool's rolling median, drawn over its line when smoothing is on. */
+const SMOOTH = "__smooth";
+/* Dotted bridges over a tool's holes, when asked for: two series, the holes in turn (`bridges`). */
+const BRIDGE = ["__bridge0", "__bridge1"] as const;
+const GAP = "color-mix(in oklab, var(--muted-foreground) 70%, transparent)";
+
+/** "2026-10-05" as "Oct 5". Read from the string: no time zone to get wrong. */
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+const shortDay = (iso: string): string => {
+ const [, month, day] = iso.split("-").map(Number);
+ const name = MONTHS[(month ?? 0) - 1];
+ return name === undefined || day === undefined ? iso : `${name} ${day}`;
+};
+
+const METRIC_ORDER: readonly Metric[] = ["total", "change", "baseline", "per-package"];
+export const METRIC_LABELS: { readonly [M in Metric]: string } = {
+ total: "Total time",
+ change: "Change since start",
+ baseline: "Relative to baseline",
+ "per-package": "Per package",
+};
+
+/** The mark a derived metric carries wherever it is chosen or drawn. */
+const Derived = () => (
+
+ Derived
+
+);
+
+export interface TimeSeriesTool {
+ readonly tool: string;
+ readonly label: string;
+}
+
+export const TimeSeriesChart = ({
+ id,
+ title,
+ rows,
+ tools,
+ section,
+ baseline,
+ window,
+ yLabel,
+ markers = [],
+ versions = [],
+ trend = [],
+ perPackage = true,
+ focus = null,
+ fixedMetric,
+ scope,
+ tabs,
+ firstTabActive,
+}: {
+ id: string;
+ title: string;
+ /** Always this metric, with no menu for it: a figure that is about one (per package, by fixture). */
+ fixedMetric?: Metric;
+ /**
+ * A prefix for the chart's own view in the URL -- scale, pin, zoom,
+ * smoothing, gaps -- so a second chart on the page keeps its own. The
+ * window, the hidden tools, the exclusions and the focus stay the page's.
+ */
+ scope?: string;
+ /** A tab row in place of the title tab (`FigureTabStrip`). */
+ tabs?: ReactNode;
+ firstTabActive?: boolean;
+ /** Dense rows from `toDenseChartRows`: holes as `null`, never as zero. */
+ rows: readonly { date: string; [tool: string]: string | number | null }[];
+ /** Every tool these files contain, in ranking order -- hidden ones included. */
+ tools: readonly TimeSeriesTool[];
+ section: SectionId;
+ baseline: string | null;
+ /**
+ * Version changes to mark on the plot, and each date's versions for the
+ * tooltip. Both empty where the keys do not name clients (registries).
+ */
+ markers?: readonly VersionMarker[];
+ versions?: readonly DatedVersions[];
+ /** What the value axis measures, in words: quantity, and over what. */
+ yLabel: string;
+ /**
+ * Each run's coverage, failures, spread and per-fixture seconds (from
+ * `loadScenario`), for the DNF marks, the band, the tooltip and the pinned
+ * run's panel.
+ */
+ trend?: readonly TrendDetail[];
+ /**
+ * Whether "Per package" can be offered: there are package counts for what
+ * is plotted. Not for task runners: nothing is installed.
+ */
+ perPackage?: boolean;
+ /** The brush's focus inside the loaded window (`?focus=`), as the server resolved it. */
+ focus?: { readonly from: string; readonly to: string } | null;
+ /** The window in effect and what may be chosen, for the range picker. */
+ window: {
+ readonly dates: readonly string[];
+ readonly end: string;
+ readonly from: string;
+ readonly to: string;
+ readonly custom: boolean;
+ };
+}) => {
+ const { resolvedTheme } = useTheme();
+ /* Server writes run in the report's transitions; a tools change leaves the lines live. */
+ const { data: reloading, startTransition, startToolsTransition } = usePending();
+ /* Reloading what is plotted: EvilCharts' own wave, with our marks off it. Under
+ reduced motion the region's still tint alone. */
+ const reduced = useReducedMotion() === true;
+ const loading = reloading && !reduced;
+ /*
+ * `hide` is the page's one tools filter -- the tools menu writes the same
+ * parameter -- and the server shapes the tables and bars from it, so that
+ * write is not shallow. The chart itself redraws at once from the new
+ * state; it does not wait for the server. `scale` and `at` are the chart's
+ * alone.
+ */
+ const [
+ { hide, scale, at, metric: chosen, smooth, gaps, exclude, y, focus: focusParam },
+ setParams,
+ ] = useQueryStates(
+ {
+ hide: { ...reportParams.hide, shallow: false, startTransition: startToolsTransition },
+ scale: reportParams.scale,
+ gaps: reportParams.gaps,
+ at: reportParams.at,
+ smooth: reportParams.smooth,
+ y: reportParams.y,
+ /* Server: these change what is loaded, or what every trend on the page
+ is computed from. */
+ metric: { ...reportParams.metric, shallow: false, startTransition },
+ exclude: { ...reportParams.exclude, shallow: false, startTransition },
+ from: { ...reportParams.from, shallow: false, startTransition },
+ to: { ...reportParams.to, shallow: false, startTransition },
+ date: { ...reportParams.date, shallow: false, startTransition },
+ /* The focus reshapes the page's other trends, not this chart's data: the
+ tools transition, which leaves the chart live rather than loading. */
+ focus: { ...reportParams.focus, shallow: false, startTransition: startToolsTransition },
+ },
+ scope === undefined
+ ? {}
+ : {
+ urlKeys: Object.fromEntries(
+ ["scale", "at", "smooth", "gaps", "y"].map((k) => [k, `${scope}-${k}`]),
+ ),
+ },
+ );
+ /* A metric this chart cannot draw is read as the measurement. */
+ const metric = fixedMetric ?? (chosen === "per-package" && !perPackage ? "total" : chosen);
+ /* A percentage change crosses zero; a log axis cannot hold it. */
+ const axis = metric === "change" ? "linear" : scale;
+ /* The value-axis zoom; one a log axis cannot hold is no zoom. */
+ const range = parseValueRange(y, axis === "log");
+
+ /* A name in the URL that these files do not contain hides nothing. */
+ const hidden = hide.filter((tool) => tools.some((t) => t.tool === tool));
+ const shown = tools.filter((t) => !hidden.includes(t.tool));
+
+ const cell = (value: unknown): number | null =>
+ typeof value === "number" && Number.isFinite(value) ? value : null;
+
+ const keys = tools.map((t) => t.tool);
+ const baselineLabel = tools.find((t) => t.tool === baseline)?.label ?? null;
+ /* What is drawn: the rows under the chosen metric. */
+ const plotted = useMemo(
+ () => metricRows(rows, keys, metric, baseline),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [rows, metric, baseline, keys.join(",")],
+ );
+ const fmt = (v: number): string =>
+ metric === "change"
+ ? `${v > 0 ? "+" : ""}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%`
+ : metric === "baseline"
+ ? `${v.toFixed(v < 10 ? 2 : 1)}×`
+ : metric === "per-package"
+ ? `${v.toFixed(v < 10 ? 2 : 1)}ms`
+ : formatDuration(v);
+ const derived = metric !== "total";
+ const detailOn = useMemo(() => new Map(trend.map((d) => [d.date, d])), [trend]);
+
+ /*
+ * The pinned run. A date with no row here, or a day nothing ran, pins
+ * nothing -- a stale or hand-written `at` is ignored, not drawn as a rule
+ * over an empty day.
+ */
+ const ran = rows.map((r) => tools.some((t) => cell(r[t.tool]) !== null));
+ const pin = at === null ? -1 : rows.findIndex((r, i) => r.date === at && ran[i]);
+
+ /** Each tool's value on the last run in range that it has one for. */
+ const latest = (tool: string): number | null => {
+ for (let i = plotted.length - 1; i >= 0; i--) {
+ const value = cell(plotted[i]?.[tool]);
+ if (value !== null) return value;
+ }
+ return null;
+ };
+
+ /*
+ * Colour lives in the config, not on , and in the shape the vendored
+ * chart reads: `colors: { light: [...] }`. This passed `color: "..."` for
+ * most of its life. The chart has no such field, ignored it without a word,
+ * and fell back to its grey for every series. Defect 6's shape again: an
+ * interface that accepts a value it does not use.
+ *
+ * One slot, under `light` only. The vendored chart scopes its dark slot to
+ * a `.dark` class this app never sets; the values are theme-switching
+ * custom properties, so the one slot is right in both schemes.
+ */
+ const paint = (tool: string): string => toolColor(tool);
+
+ /*
+ * NO POINT MARKERS on the lines: ten lines of thirty dots is a field of
+ * dots. But a line with no markers draws a run only as part of a segment,
+ * and a run with a hole on both sides has no segment -- it would simply not
+ * be drawn. So each tool that has such runs gets a second, marker-only
+ * series holding just those (`loneRuns`), in the same colour. Nothing
+ * published is dropped from the plot.
+ */
+ const lone = useMemo(() => {
+ const flags = new Map();
+ for (const { tool } of shown) {
+ const marks = loneRuns(plotted.map((r) => cell(r[tool])));
+ if (marks.some(Boolean)) flags.set(tool, marks);
+ }
+ return flags;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [plotted, shown.map((t) => t.tool).join(",")]);
+
+ /*
+ * SMOOTHING draws a rolling median (three runs either side) over each tool,
+ * and thins the raw line under it rather than removing it: the median is a
+ * reading aid, the runs are the measurement.
+ */
+ const smoothed = smooth === "median7";
+ /* GAPS: a hole by default; on request, a dotted bridge over it -- its own series, never the line. */
+ const bridged = gaps === "connect";
+ const data = useMemo(
+ () => {
+ const medians = smoothed
+ ? new Map(shown.map(({ tool }) => [tool, rollingMedian(plotted.map((r) => cell(r[tool])))]))
+ : null;
+ const spans = bridged
+ ? new Map(shown.map(({ tool }) => [tool, bridges(plotted.map((r) => cell(r[tool])))]))
+ : null;
+ return plotted.map((row, i) => {
+ const out: Record = { ...row };
+ for (const [tool, marks] of lone) out[`${tool}${LONE}`] = marks[i] ? row[tool] : null;
+ if (medians !== null)
+ for (const [tool, m] of medians) out[`${tool}${SMOOTH}`] = m[i] ?? null;
+ if (spans !== null)
+ for (const [tool, pair] of spans)
+ BRIDGE.forEach((key, k) => (out[`${tool}${key}`] = pair[k]?.[i] ?? null));
+ return out;
+ });
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [plotted, lone, smoothed, bridged, shown.map((t) => t.tool).join(",")],
+ );
+
+ const config: ChartConfig = useMemo(
+ () =>
+ Object.fromEntries(
+ shown.flatMap(({ tool, label }) => [
+ [tool, { label, colors: { light: [paint(tool)] } }],
+ ...(lone.has(tool)
+ ? [[`${tool}${LONE}`, { label, colors: { light: [paint(tool)] } }]]
+ : []),
+ ...(smoothed ? [[`${tool}${SMOOTH}`, { label, colors: { light: [paint(tool)] } }]] : []),
+ /* Grey, not the tool's: a bridge is the absence of a run, and must not read as one. */
+ ...(bridged
+ ? BRIDGE.map((key) => [`${tool}${key}`, { label, colors: { light: [GAP] } }])
+ : []),
+ ]),
+ ),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [shown.map((t) => t.tool).join(","), lone, smoothed, bridged],
+ );
+
+ /*
+ * VERSION CHANGES ARE ON THE PLOT: a ring on a tool's line at the run where
+ * its version changed. They were a list of thirty rows under the chart,
+ * which nobody could hold against the lines. The ring says where; the
+ * tooltip says what. The pinned run's dots are the same kind of mark.
+ *
+ * These are elements of ours laid over the plot, not series symbols. As
+ * symbols they were dropped wherever the axis ran short of room -- ECharts
+ * thins a series' symbols to the labelled ticks -- so at phone width most
+ * of the rings, and every pinned dot off a tick, were simply not drawn.
+ */
+ const changed = useMemo(() => new Map(markers.map((m) => [`${m.tool} ${m.date}`, m])), [markers]);
+ /*
+ * Runs far from their neighbours (`flagOutliers`), judged on the measured
+ * seconds whatever the metric: a dashed ring, and a line in the tooltip.
+ * Flagged, never removed -- removing is the reader's call, in the URL.
+ */
+ const unusual = useMemo(
+ () => new Map(keys.map((tool) => [tool, flagOutliers(rows.map((r) => cell(r[tool])))])),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [rows, keys.join(",")],
+ );
+ type Mark = {
+ tool: string;
+ i: number;
+ value: number;
+ kind: "ring" | "pin" | "flag" | "dnf";
+ lane?: number;
+ };
+ const marks: Mark[] = shown.flatMap(({ tool }) =>
+ plotted.flatMap((row, i): Mark[] => {
+ const value = cell(row[tool]);
+ if (value === null) {
+ /* DID NOT FINISH: the line breaks, and a cross on a lane under the
+ date labels says why there is no point -- off the plot, so it can
+ never be read as a value. */
+ const d = detailOn.get(row.date);
+ const dnf =
+ d !== undefined && (d.partial[tool] !== undefined || (d.failed[tool]?.length ?? 0) > 0);
+ return dnf ? [{ tool, i, value: 0, kind: "dnf" }] : [];
+ }
+ return [
+ ...(changed.has(`${tool} ${row.date}`) ? [{ tool, i, value, kind: "ring" as const }] : []),
+ ...(unusual.get(tool)?.[i] != null ? [{ tool, i, value, kind: "flag" as const }] : []),
+ ...(i === pin ? [{ tool, i, value, kind: "pin" as const }] : []),
+ ];
+ }),
+ );
+ /*
+ * ONE LANE OF DNFs, a mark per run: a lane per tool stacked down into the
+ * brush once a few tools had failed. The mark is the tool's colour when one
+ * failed that run, and ink with a count when several did; the tooltip names
+ * them.
+ */
+ const failedOn = new Map();
+ for (const m of marks)
+ if (m.kind === "dnf") failedOn.set(m.i, [...(failedOn.get(m.i) ?? []), m.tool]);
+ /*
+ * The tool singled out by a click on its line: the others' marks dim with
+ * their lines. Kept with the chart it was made on, because a remounted
+ * chart starts with nothing singled out.
+ */
+ const chartKey = `${resolvedTheme}:${window.from}:${window.to}`;
+ const [single, setSingle] = useState<{ chart: string; tool: string } | null>(null);
+ const only = single?.chart === chartKey ? single.tool : null;
+
+ const measured =
+ metric === "change"
+ ? "Change since the first run in range"
+ : metric === "baseline"
+ ? `Time relative to ${baselineLabel ?? "the baseline"}`
+ : metric === "per-package"
+ ? "Install time per package"
+ : yLabel;
+ const yTitle = `${measured}${derived ? " · derived" : ""}${axis === "log" ? " · log scale" : ""}${
+ range === null ? "" : range === "fit" ? " · fit to data" : " · zoomed"
+ }`;
+ const tick = (v: number) => (v === 0 && metric !== "change" ? "0" : fmt(v));
+
+ /*
+ * The stock brush adds an "inside" zoom as well as the slider, and ECharts
+ * turns the mouse wheel into zoom for it. So scrolling the PAGE with the
+ * pointer over the plot zoomed the chart instead: the range control said
+ * "Last 30 days" and the plot showed a week. The brush opens on everything
+ * that was loaded (the component's default); this keeps it there until the
+ * reader moves it on purpose. Wheel events are stopped on the way down, so
+ * the chart never sees them and the page scrolls as it should. Dragging the
+ * brush, and the plot, still work.
+ */
+ const plot = useRef(null);
+ useEffect(() => {
+ const el = plot.current;
+ if (el === null) return;
+ const stop = (event: WheelEvent) => event.stopPropagation();
+ el.addEventListener("wheel", stop, { capture: true, passive: true });
+ return () => el.removeEventListener("wheel", stop, { capture: true });
+ }, []);
+
+ /*
+ * THE PIN. A click on the plot pins the nearest published run, and the same
+ * click again releases it. A click on a LINE is not this: the line takes it
+ * and singles its tool out, as before. The brush strip is outside the plot's
+ * grid, so dragging it pins nothing.
+ *
+ * The rule and the marks are placed from the chart's own coordinates after
+ * every render, so they follow the brush, the scale and a resize. The marks
+ * wait for the draw-in to finish rather than stand on an empty plot.
+ */
+ const rule = useRef(null);
+ const over = useRef(null);
+ const band = useRef(null);
+ const layer = useRef(null);
+
+ /*
+ * THE VALUE-AXIS ZOOM (`y`). A vertical drag on the axis-label strip left of
+ * the plot selects a band of values; release zooms the axis to it. The strip
+ * is an element of ours over the labels, so the drag never reaches the plot:
+ * the plot's own drag (the brush) and click (the pin) are untouched. A drag
+ * shorter than a few pixels is a slip, not a zoom.
+ */
+ const gutter = useRef(null);
+ const selection = useRef(null);
+ type Rect = { x: number; y: number; width: number; height: number };
+ const gridAt = useRef(null);
+ const dragFrom = useRef(null);
+ const pointerY = (event: Pointer): number => {
+ const g = gridAt.current;
+ const top = plot.current?.getBoundingClientRect().top ?? 0;
+ return g === null ? NaN : Math.min(Math.max(event.clientY - top, g.y), g.y + g.height);
+ };
+ const select = (a: number | null, b = a) => {
+ const el = selection.current;
+ const g = gridAt.current;
+ if (el === null) return;
+ el.hidden = a === null || b === null || g === null;
+ if (a === null || b === null || g === null) return;
+ Object.assign(el.style, {
+ left: `${g.x}px`,
+ width: `${g.width}px`,
+ top: `${Math.min(a, b)}px`,
+ height: `${Math.abs(a - b)}px`,
+ });
+ };
+ const endDrag = (event: Pointer) => {
+ const from = dragFrom.current;
+ dragFrom.current = null;
+ select(null);
+ if (from === null || event.type !== "pointerup") return;
+ const to = pointerY(event);
+ if (!(Math.abs(to - from) >= 4)) return;
+ const mount = plot.current?.querySelector("[_echarts_instance_]");
+ const chart = mount ? echarts.getInstanceByDom(mount) : undefined;
+ if (chart === undefined) return;
+ const [a, b] = [from, to].map((p) => Number(chart.convertFromPixel({ yAxisIndex: 0 }, p)));
+ const next = formatValueRange(a ?? NaN, b ?? NaN, axis === "log");
+ if (parseValueRange(next, axis === "log") !== null) void setParams({ y: next });
+ };
+
+ /*
+ * THE BAND: for the tool singled out by a click on its line, ±1 standard
+ * deviation of its repetitions around each run (`rsd`, pooled across
+ * fixtures). Only on a metric that is a time -- a percentage of a
+ * percentage is not a spread. Drawn by `place` in the chart's coordinates.
+ */
+ const bandRuns = (tool: string | null): { i: number; lo: number; hi: number }[] =>
+ tool === null || metric === "change" || metric === "baseline"
+ ? []
+ : plotted.flatMap((row, i) => {
+ const v = cell(row[tool]);
+ const r = detailOn.get(row.date)?.rsd[tool];
+ return v === null || r === undefined ? [] : [{ i, lo: v * (1 - r), hi: v * (1 + r) }];
+ });
+ const drawn = rows.length > 0 && shown.length > 0;
+ /*
+ * The vendored chart can replace its ECharts instance without remounting
+ * (after a brush commit's reload, for one), and handlers bound to the old
+ * instance then hear nothing: the pin click went dead. Watch the mount for a
+ * new instance id and rebind.
+ */
+ const [instance, setInstance] = useState(null);
+ const appliedFocus = useRef(null);
+ /*
+ * A focus cleared elsewhere -- its chip -- releases the brush back to
+ * everything. Read from the address, which changes at once, not from the
+ * server's `focus`, which lags a release by a round trip and would undo a
+ * drag that has only just set it.
+ */
+ const lastFocus = useRef(focusParam);
+ useEffect(() => {
+ const was = lastFocus.current;
+ lastFocus.current = focusParam;
+ if (was === null || focusParam !== null) return;
+ appliedFocus.current = null;
+ const mount = plot.current?.querySelector("[_echarts_instance_]");
+ const chart = mount ? echarts.getInstanceByDom(mount) : undefined;
+ const zoom = (
+ chart?.getOption() as { dataZoom?: { start?: number; end?: number }[] } | undefined
+ )?.dataZoom?.[0];
+ if (
+ chart !== undefined &&
+ zoom !== undefined &&
+ ((zoom.start ?? 0) > 0 || (zoom.end ?? 100) < 100)
+ ) {
+ chart.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
+ }
+ }, [focusParam]);
+ useEffect(() => {
+ const host = plot.current;
+ if (host === null) return;
+ const read = () =>
+ setInstance(
+ host
+ .querySelector("[_echarts_instance_]")
+ ?.getAttribute("_echarts_instance_") ?? null,
+ );
+ read();
+ const observer = new MutationObserver(read);
+ observer.observe(host, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["_echarts_instance_"],
+ });
+ return () => observer.disconnect();
+ }, []);
+ useEffect(() => {
+ const mount = plot.current?.querySelector("[_echarts_instance_]");
+ const chart = mount ? echarts.getInstanceByDom(mount) : undefined;
+ if (chart === undefined) return;
+
+ const onClick = (event: { offsetX: number; offsetY: number; target?: unknown }) => {
+ const point = [event.offsetX, event.offsetY];
+ if (event.target != null || !chart.containPixel({ gridIndex: 0 }, point)) return;
+ const [x] = chart.convertFromPixel({ gridIndex: 0 }, point);
+ const date = rows[nearestRun(ran, Math.round(x ?? -1))]?.date;
+ if (date !== undefined) void setParams({ at: date === at ? null : date });
+ };
+
+ const place = () => {
+ const el = rule.current;
+ if (el === null || over.current === null) return;
+ /* The grid's rectangle is not in the public API; this is where it lives. */
+ const grid = (
+ chart as unknown as {
+ getModel(): {
+ getComponent(type: "grid"): {
+ coordinateSystem: {
+ getRect(): { x: number; y: number; width: number; height: number };
+ };
+ };
+ };
+ }
+ )
+ .getModel()
+ .getComponent("grid")
+ .coordinateSystem.getRect();
+ gridAt.current = grid;
+ const inside = (x: number) => x >= grid.x - 1 && x <= grid.x + grid.width + 1;
+ /* A zoomed value axis leaves marks above and below the plot: not drawn. */
+ const level = (y: number) => y >= grid.y - 1 && y <= grid.y + grid.height + 1;
+ if (gutter.current !== null) {
+ Object.assign(gutter.current.style, {
+ top: `${grid.y}px`,
+ width: `${grid.x}px`,
+ height: `${grid.height}px`,
+ });
+ gutter.current.hidden = false;
+ }
+ /* The band, too, stays on the plot, whichever axis is zoomed. */
+ layer.current?.style.setProperty(
+ "clip-path",
+ `inset(${grid.y}px calc(100% - ${grid.x + grid.width}px) calc(100% - ${grid.y + grid.height}px) ${grid.x}px)`,
+ );
+ const x = pin < 0 ? NaN : chart.convertToPixel({ xAxisIndex: 0 }, pin);
+ el.hidden = !inside(x);
+ el.style.left = `${Math.round(x)}px`;
+ el.style.top = `${grid.y}px`;
+ el.style.height = `${grid.height}px`;
+ for (const mark of over.current.children as HTMLCollectionOf) {
+ const lane = mark.dataset.lane;
+ const [mx = NaN, my = NaN] =
+ lane === undefined
+ ? chart.convertToPixel({ gridIndex: 0 }, [
+ Number(mark.dataset.i),
+ Number(mark.dataset.value),
+ ])
+ : [
+ chart.convertToPixel({ xAxisIndex: 0 }, Number(mark.dataset.i)),
+ grid.y + grid.height + 27 + Number(lane) * 11,
+ ];
+ mark.hidden = !inside(mx) || Number.isNaN(my) || (lane === undefined && !level(my));
+ mark.style.left = `${mx}px`;
+ mark.style.top = `${my}px`;
+ }
+ /* The band: one closed shape per unbroken stretch of runs. */
+ if (band.current !== null) {
+ const runs = bandRuns(band.current.dataset.tool ?? null);
+ const at = (i: number, v: number) => chart.convertToPixel({ gridIndex: 0 }, [i, v]);
+ const stretches: (typeof runs)[] = [];
+ for (const run of runs) {
+ const last = stretches.at(-1);
+ if (last !== undefined && last.at(-1)?.i === run.i - 1) last.push(run);
+ else stretches.push([run]);
+ }
+ band.current.setAttribute(
+ "d",
+ stretches
+ .filter((s) => s.length > 1)
+ .map((s) => {
+ const top = s.map((r) => at(r.i, r.hi));
+ const bottom = s.map((r) => at(r.i, r.lo)).reverse();
+ return `M${[...top, ...bottom].map(([x, y]) => `${x},${y}`).join("L")}Z`;
+ })
+ .join(""),
+ );
+ }
+ };
+ const reveal = () => {
+ if (over.current !== null) over.current.hidden = false;
+ };
+
+ /*
+ * LINKED BRUSHING. When a drag that began on this chart ends with the
+ * brush narrowed, its selection becomes the page's `focus`, snapped to
+ * published runs. Not a new window: the chart keeps all it loaded and its
+ * brush stays where it was let go, while the page's other trends (Trend
+ * by tool, the leaderboard's sparklines) are recomputed over the focus.
+ * Dragging back out to everything clears it, as does its chip.
+ */
+ let dragging = false;
+ const onDown = () => {
+ dragging = true;
+ };
+ const onUp = () => {
+ if (!dragging) return;
+ dragging = false;
+ const zoom = (
+ chart.getOption() as {
+ dataZoom?: { start?: number; end?: number; startValue?: number; endValue?: number }[];
+ }
+ ).dataZoom?.[0];
+ if (zoom === undefined) return;
+ /* Dragged back out to everything: no focus. */
+ if ((zoom.start ?? 0) <= 0 && (zoom.end ?? 100) >= 100) {
+ if (focus !== null) void setParams({ focus: null });
+ return;
+ }
+ const first = rows[nearestRun(ran, Number(zoom.startValue))]?.date;
+ const last = rows[nearestRun(ran, Number(zoom.endValue))]?.date;
+ if (first === undefined || last === undefined || first >= last) return;
+ if (first === focus?.from && last === focus?.to) return;
+ void setParams({ focus: `${first}~${last}`, at: null });
+ };
+ const host = plot.current;
+ host?.addEventListener("pointerdown", onDown);
+ document.addEventListener("pointerup", onUp);
+
+ /* A focus from the address opens the brush on it -- once per instance, and
+ only while the brush still shows everything, so it never fights a drag. */
+ if (focus !== null && appliedFocus.current !== `${instance}:${focus.from}~${focus.to}`) {
+ const zoom = (chart.getOption() as { dataZoom?: { start?: number; end?: number }[] })
+ .dataZoom?.[0];
+ const a = rows.findIndex((row) => row.date === focus.from);
+ const b = rows.findIndex((row) => row.date === focus.to);
+ if (
+ zoom !== undefined &&
+ (zoom.start ?? 0) <= 0 &&
+ (zoom.end ?? 100) >= 100 &&
+ a >= 0 &&
+ b > a
+ ) {
+ chart.dispatchAction({ type: "dataZoom", startValue: a, endValue: b });
+ }
+ appliedFocus.current = `${instance}:${focus.from}~${focus.to}`;
+ }
+
+ const zr = chart.getZr();
+ zr.on("click", onClick);
+ chart.on("rendered", place);
+ chart.on("finished", reveal);
+ place();
+ /* A remount can finish its draw-in before this listener is attached. */
+ const fallback = setTimeout(reveal, 1500);
+ return () => {
+ clearTimeout(fallback);
+ host?.removeEventListener("pointerdown", onDown);
+ document.removeEventListener("pointerup", onUp);
+ if (chart.isDisposed()) return;
+ zr.off("click", onClick);
+ chart.off("rendered", place);
+ chart.off("finished", reveal);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chartKey, instance, drawn, rows, plotted, pin, at, marks.length, only, focus]);
+
+ useEffect(() => {
+ if (pin < 0) return;
+ const onKey = (event: KeyboardEvent) => {
+ /* Escape inside an open menu or popover closes that, and only that. */
+ if (event.key !== "Escape") return;
+ if ((event.target as Element).closest?.("[role=menu],[role=dialog]")) return;
+ void setParams({ at: null });
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [pin, setParams]);
+
+ const versionsOn = useMemo(() => new Map(versions.map((v) => [v.date, v.versions])), [versions]);
+
+ /*
+ * A log axis ticks only on whole decades, so a zoom inside one decade would
+ * label nothing but its ends. Inside a decade it gets five even steps of
+ * its own (`interval` is in log units), each labelled with its value.
+ */
+ const decades = (lo: number, hi: number) => {
+ const span = Math.log10(hi / lo);
+ return span > 0 && span < 1 ? { interval: span / 5 } : {};
+ };
+ const extent = useMemo((): [number, number] => {
+ const values = shown.flatMap(({ tool }) =>
+ plotted.flatMap((r) => {
+ const v = cell(r[tool]);
+ return v !== null && v > 0 ? [v] : [];
+ }),
+ );
+ return [Math.min(...values), Math.max(...values)];
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [plotted, shown.map((t) => t.tool).join(",")]);
+
+ const chartOptions = useMemo(
+ () => ({
+ tooltip: {
+ ...tooltipBaseOption({
+ present: true,
+ cursor: true,
+ tokens: { mutedForeground: "", border: "", foreground: "", background: "" },
+ position: "variable",
+ axisPointerColor: "var(--border)",
+ strokeWidth: 1,
+ }),
+ formatter: (
+ items: {
+ seriesId?: string;
+ seriesName: string;
+ value: number | null;
+ axisValue: string;
+ }[],
+ ) => {
+ /* A tool's own line only: not its marker series, not the brush's. */
+ const real = items.filter((i) => !String(i.seriesId ?? "").includes("__"));
+ const date = items[0]?.axisValue ?? "";
+ const index = rows.findIndex((r) => r.date === date);
+ const detail = detailOn.get(date);
+ const ran = real
+ .filter((i) => typeof i.value === "number")
+ .sort((a, b) => (b.value as number) - (a.value as number));
+ const muted = (text: string) =>
+ `${escapeHtml(text)}`;
+ /* Seconds against the tool's previous run in range. */
+ const since = (tool: string): string => {
+ const now = cell(rows[index]?.[tool]);
+ for (let k = index - 1; k >= 0 && now !== null; k--) {
+ const before = cell(rows[k]?.[tool]);
+ if (before !== null && before > 0) {
+ const f = now / before - 1;
+ return ` ${muted(`${f > 0 ? "+" : ""}${(f * 100).toFixed(1)}%`)}`;
+ }
+ }
+ return "";
+ };
+ const lines = ran.map((i) => {
+ const tool = String(i.seriesId ?? "");
+ const change = changed.get(`${tool} ${date}`);
+ const note = versionNote(versionsOn.get(date)?.[tool], change);
+ const step =
+ change === undefined
+ ? null
+ : stepChange(
+ rows.map((r) => cell(r[tool])),
+ index,
+ );
+ const odd = unusual.get(tool)?.[index] ?? null;
+ const extra = [
+ ...(step === null
+ ? []
+ : [`median ${step.change > 0 ? "+" : ""}${(step.change * 100).toFixed(1)}% after`]),
+ ...(odd === null
+ ? []
+ : [`unusual: ${odd > 0 ? "+" : ""}${(odd * 100).toFixed(0)}% vs 7-run median`]),
+ ];
+ return tooltipRow({
+ indicatorHtml: tooltipIndicatorHtml(tool, 1),
+ /* The version it ran, muted; a change, in ink. */
+ labelText:
+ escapeHtml(i.seriesName) +
+ (note === ""
+ ? ""
+ : ` ${escapeHtml(note)}`) +
+ (extra.length === 0
+ ? ""
+ : ` ${escapeHtml(extra.join(" · "))}`),
+ valueText: fmt(i.value as number) + (metric === "total" ? since(tool) : ""),
+ dimmed: "",
+ });
+ });
+ /* Tools with no point here because they did not finish. */
+ const dnf = shown.flatMap(({ tool, label }) => {
+ if (detail === undefined || cell(plotted[index]?.[tool]) !== null) return [];
+ const failed = detail.failed[tool] ?? [];
+ const partial = detail.partial[tool];
+ if (failed.length === 0 && partial === undefined) return [];
+ const why = failed
+ .map((f) => `${f.fixture}${Number.isNaN(f.exit) ? "" : ` (exit ${f.exit})`}`)
+ .join(", ");
+ return [
+ tooltipRow({
+ indicatorHtml: tooltipIndicatorHtml(tool, 1),
+ labelText: `${escapeHtml(label)} ${muted(why === "" ? "missing a fixture" : `DNF ${why}`)}`,
+ valueText:
+ partial === undefined
+ ? "DNF"
+ : `DNF ${muted(`${formatDuration(partial.mean)} on ${partial.of}/${detail.fixtures.length}`)}`,
+ dimmed: "",
+ }),
+ ];
+ });
+ const most = Math.max(0, ...trend.map((d) => d.fixtures.length));
+ const short =
+ detail !== undefined && detail.fixtures.length < most
+ ? `
${detail.fixtures.length} of ${most} fixtures ran
`
+ : "";
+ return tooltipShell({
+ label: escapeHtml(shortDay(date)),
+ body:
+ ran.length === 0 && dnf.length === 0
+ ? `No run published`
+ : short + [...lines, ...dnf].join(""),
+ roundness: "lg",
+ variant: "default",
+ });
+ },
+ },
+ ...(axis === "log" || range !== null
+ ? {
+ /*
+ * Two axes, because the brush is a second grid with its own: the
+ * component builds `[main, mini]` and a shallow merge replaces
+ * both. The mini axis is theirs (`{ type, gridIndex: 1, show:
+ * false }`) with the same change of type, so the brush's
+ * miniature is on the scale the plot is on -- and never zoomed:
+ * it shows everything that was loaded.
+ */
+ yAxis: [
+ {
+ type: axis === "log" ? "log" : "value",
+ /* The zoom. `scale` lets a linear axis leave zero; a log axis
+ rounds out to whole decades unless it is given its ends. */
+ ...(range === null
+ ? {}
+ : range === "fit"
+ ? axis === "log"
+ ? { min: "dataMin", max: "dataMax", ...decades(...extent) }
+ : { scale: true }
+ : {
+ min: range[0],
+ max: range[1],
+ ...(axis === "log" ? decades(...range) : {}),
+ }),
+ show: true,
+ /* The axis title, as the component sets its own. */
+ name: yTitle,
+ nameLocation: "middle",
+ nameGap: 38,
+ nameTextStyle: { color: EVIL_AXIS_LABEL.color, fontSize: EVIL_AXIS_LABEL.fontSize },
+ axisLine: { show: false },
+ axisTick: EVIL_TICK_DOT,
+ splitLine: EVIL_SPLIT_LINE,
+ axisLabel: {
+ ...EVIL_AXIS_LABEL,
+ formatter: tick,
+ },
+ },
+ { type: axis === "log" ? "log" : "value", gridIndex: 1, show: false },
+ ],
+ }
+ : {}),
+ }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ axis,
+ y,
+ extent,
+ yTitle,
+ changed,
+ versionsOn,
+ metric,
+ rows,
+ plotted,
+ detailOn,
+ unusual,
+ shown.length,
+ ],
+ );
+
+ return (
+
+ {/* First in the row, so nothing else moves when it appears. */}
+ {range === null ? null : (
+
+ )}
+
+ {/* What is plotted. Total time is the measurement; the rest are
+ derived from it and labelled so, here and on the axis. */}
+ {fixedMetric !== undefined ? null : (
+
+
+
+
+
+
+ void setParams({
+ metric: METRIC_ORDER.find((m) => m === value) ?? "total",
+ at: null,
+ /* A zoom is in the old metric's units. */
+ y: null,
+ })
+ }
+ >
+ {METRIC_ORDER.filter((m) => m !== "per-package" || perPackage)
+ .filter((m) => m !== "baseline" || baseline !== null)
+ .map((m) => (
+
+ {m === "baseline"
+ ? `Relative to ${baselineLabel ?? "baseline"}`
+ : METRIC_LABELS[m]}
+ {m === "total" ? null : }
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+ Scale
+
+
+ void setParams({ scale: value === "log" ? "log" : "linear" })
+ }
+ >
+ Linear
+
+ Log
+
+
+
+
+ Value range
+
+ {/* A dragged range checks neither; "Reset zoom" names it. */}
+ void setParams({ y: value === "fit" ? "fit" : null })}
+ >
+ Full
+ Fit to shown tools
+
+
+ Or drag along the axis to zoom.
+
+
+
+ Smoothing
+
+
+ void setParams({ smooth: value === "median7" ? "median7" : "off" })
+ }
+ >
+ Off
+ 7-run median
+
+
+
+ Missed runs
+
+
+ void setParams({ gaps: value === "connect" ? "connect" : "hole" })
+ }
+ >
+
+ Bridge with a dotted line
+
+ Leave a gap
+
+
+
+ >
+ }
+ >
+
+ {pin < 0 || at === null ? null : (
+ {
+ const values = rows.map((r) => cell(r[tool]));
+ const change = changed.get(`${tool} ${at}`);
+ const step = change === undefined ? null : stepChange(values, pin);
+ const odd = unusual.get(tool)?.[pin] ?? null;
+ const signed = (f: number) => `${f > 0 ? "+" : ""}${(f * 100).toFixed(1)}%`;
+ return [
+ ...(change === undefined
+ ? []
+ : [
+ {
+ tool,
+ kind: "version" as const,
+ text: versionNote(versionsOn.get(at)?.[tool], change),
+ detail:
+ step === null
+ ? undefined
+ : `median ${signed(step.change)} over the ${step.after} runs from here, against the ${step.before} before`,
+ },
+ ]),
+ ...(odd === null
+ ? []
+ : [
+ {
+ tool,
+ kind: "unusual" as const,
+ text: "unusual run",
+ detail: `${signed(odd)} against the median of the runs around it`,
+ },
+ ]),
+ ];
+ })}
+ tools={shown
+ .map(({ tool }) => {
+ const now = cell(rows[pin]?.[tool]);
+ let before: number | null = null;
+ for (let k = pin - 1; k >= 0 && before === null; k--)
+ before = cell(rows[k]?.[tool]);
+ const base = baseline === null ? null : cell(rows[pin]?.[baseline]);
+ const value = cell(plotted[pin]?.[tool]);
+ return {
+ tool,
+ sort: value ?? Infinity,
+ value,
+ shown: value === null ? null : fmt(value),
+ sincePrevious:
+ now === null || before === null || before <= 0 ? null : now / before - 1,
+ versusBaseline:
+ now === null || base === null || base <= 0 ? null : now / base,
+ version: versionsOn.get(at)?.[tool] ?? null,
+ };
+ })
+ .sort((a, b) => a.sort - b.sort)}
+ onOpen={() => void setParams({ date: at, at: null, from: null, to: null })}
+ onExclude={(entry) =>
+ void setParams({ exclude: [...new Set([...exclude, entry])], at: null })
+ }
+ onClose={() => void setParams({ at: null })}
+ />
+ )}
+ m.kind))}
+ baseline={baseline !== null && shown.some((t) => t.tool === baseline)}
+ lone={lone.size > 0}
+ bridged={
+ bridged && shown.some(({ tool }) => plotted.some((r) => cell(r[tool]) === null))
+ }
+ />
+ {
+ const value = pin < 0 ? latest(tool) : cell(plotted[pin]?.[tool]);
+ return {
+ tool,
+ on: !hidden.includes(tool),
+ value: value === null ? (pin < 0 ? null : "—") : fmt(value),
+ };
+ })}
+ onToggle={(tool) =>
+ void setParams({
+ hide: hidden.includes(tool)
+ ? hidden.filter((t) => t !== tool)
+ : [...hidden, tool],
+ })
+ }
+ onShowAll={() => void setParams({ hide: [] })}
+ />
+ >
+ }
+ >
+ {exclude.length === 0 ? null : (
+ /* Every exclusion, visible and reversible: they are in the URL and
+ in every trend on the page, so they are never silent. */
+
+ {[...failedOn].map(([i, failed]) => (
+ /* DID NOT FINISH: an icon on the lane under the dates, off the plot. */
+
+
+ {failed.length === 1 ? null : failed.length}
+
+ ))}
+ {marks.map(({ tool, i, value, kind }) => {
+ const dim = only !== null && only !== tool ? "opacity-30" : "";
+ if (kind === "dnf") return null;
+ if (kind === "pin")
+ return (
+
+ );
+ /* A version change (a tag) or an unusual run (a warning): an icon on the line, in its colour. */
+ const Icon = kind === "ring" ? Tag : TriangleAlert;
+ return (
+
+
+
+ );
+ })}
+
+ {rows.length === 0 ? (
+
No runs in range to draw.
+ ) : shown.length === 0 ? (
+
Every tool is hidden.
+ ) : (
+
+ queueMicrotask(() => setSingle(tool === null ? null : { chart: chartKey, tool }))
+ }
+ className={PLOT}
+ >
+
+ (v.length === 10 ? shortDay(v) : v)}
+ />
+
+
+ {/*
+ * EvilCharts' brush, as its docs wire it: a miniature of every
+ * loaded run under the plot, with a draggable window that zooms
+ * the plot on the client. Nothing refetches -- the date range
+ * above decides what is loaded; this looks closer at it.
+ */}
+ shortDay(String(v))} />
+ {shown.map(({ tool }) => (
+
+ * 2026-06-29 fixture.
+ */
+ connectNulls={false}
+ /* Every tool the same weight. The stock 0.8px relied on its
+ dots to be seen; without them it needs a little more. */
+ strokeWidth={smoothed ? 0.6 : STROKE}
+ strokeVariant={tool === baseline ? "dashed" : "solid"}
+ isClickable
+ >
+
+
+ ))}
+ {smoothed
+ ? shown.map(({ tool }) => (
+
+ ))
+ : null}
+ {bridged
+ ? shown.flatMap(({ tool }) =>
+ BRIDGE.map((key) => (
+
+ )),
+ )
+ : null}
+ {[...lone.keys()].map((tool) => (
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+/**
+ * What the marks on the plot mean, for the ones in view: an icon a reader has
+ * to guess at is decoration. Neutral ink, since each mark is drawn in its
+ * tool's colour.
+ */
+const MarkKey = ({
+ kinds,
+ baseline,
+ lone,
+ bridged,
+}: {
+ kinds: ReadonlySet;
+ baseline: boolean;
+ lone: boolean;
+ bridged: boolean;
+}) => {
+ const items = [
+ kinds.has("ring") && {
+ icon: ,
+ text: "Version changed",
+ },
+ kinds.has("flag") && {
+ icon: ,
+ text: "Unusual run (far from the runs around it)",
+ },
+ kinds.has("dnf") && {
+ icon: ,
+ text: "Did not finish: under the dates; a number, how many tools",
+ bare: true,
+ },
+ lone && {
+ icon: ,
+ text: "A run with no run either side",
+ bare: true,
+ },
+ bridged && {
+ icon: ,
+ text: "Bridges a missed run: no time there",
+ bare: true,
+ },
+ baseline && {
+ icon: ,
+ text: "Dashed: the baseline, which multiples are taken against",
+ bare: true,
+ },
+ ].filter((item) => item !== false);
+ if (items.length === 0) return null;
+ return (
+
+ {items.map(({ icon, text, bare }) => (
+
+
+ {icon}
+
+ {text}
+
+ ))}
+
+ );
+};
+
+/** The trend's frame while the report loads: EvilCharts' loading wave, or a still tint under reduced motion. */
+export const TrendSkeleton = () => {
+ const reduced = useReducedMotion() === true;
+ return (
+
+
+ {reduced ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/www/src/components/charts-figures.tsx b/apps/www/src/components/charts-figures.tsx
new file mode 100644
index 0000000000..dcaa1b3b62
--- /dev/null
+++ b/apps/www/src/components/charts-figures.tsx
@@ -0,0 +1,381 @@
+"use client";
+
+import * as echarts from "echarts/core";
+import type { ReactNode } from "react";
+import { BarChart } from "echarts/charts";
+import { GridComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
+import { useChart } from "@/components/charts-echarts-dots";
+import { ChartFrame } from "@/components/report";
+import { ECHARTS_RENDERERS } from "@/components/ui/echarts-chart";
+import { toolColor } from "@/lib/chart-model";
+import {
+ EVIL_AXIS_LABEL,
+ EVIL_AXIS_TEXT,
+ EVIL_BORDER,
+ EVIL_INK,
+ EVIL_LABEL_TEXT,
+ EVIL_MUTED,
+ EVIL_REVEAL,
+ EVIL_SPLIT_LINE,
+ EVIL_TICK_DOT,
+} from "@/lib/chart-style";
+import { formatDuration, formatVersus } from "@/lib/findings";
+import { formatMs } from "@/lib/measurements";
+
+/* Renderers are registered by the vendored base chart, imported above. */
+echarts.use([BarChart, GridComponent, MarkLineComponent, TooltipComponent]);
+
+/**
+ * The figure EvilCharts has no component that can do the job for: bars by
+ * tool. (The time series is the stock EvilCharts line chart, in
+ * `charts-echarts.tsx`.)
+ *
+ * It is ECharts options on EvilCharts' vendored base -- its renderer
+ * registration and constants -- and takes every styling value from
+ * `lib/chart-style.ts`, which is the stock line chart's own: the same tick
+ * dots, the same dashed split lines, the same axis text, the same frame. A
+ * bar chart beside a line chart is the same chart drawn with different marks.
+ *
+ * Why not EvilCharts' bar component: no value labels on bars, and it
+ * separates stacked segments by a fixed 4px, which moved a bar's origin off
+ * zero.
+ *
+ * COLOUR IS THE TOOL'S BRAND: `toolColor()` resolves to the `--color-tool-*`
+ * custom property, given to ECharts as a `var()` string, so the browser
+ * resolves it and re-resolves it on a theme change. Brand colours are not
+ * mutually distinguishable (FRONTEND.md, measured), so colour is never the
+ * only carrier: bars are labelled on their axis. The baseline is additionally
+ * DASHED.
+ *
+ * Guarded by inventory 18: the SVG renderer, and a bar axis that starts at
+ * zero.
+ */
+const RENDERER = ECHARTS_RENDERERS.svg;
+
+export interface Named {
+ readonly tool: string;
+ readonly label: string;
+}
+
+/* An axis title, as the stock line chart sets its own: the axis text's size
+ and colour, centred on the axis. */
+const axisTitle = (name: string, gap: number) =>
+ ({
+ name,
+ nameLocation: "middle",
+ nameGap: gap,
+ nameTextStyle: { color: EVIL_MUTED, fontSize: EVIL_AXIS_TEXT },
+ }) as const;
+
+/** ECharts measures text itself, so it needs the page's real font family. */
+const fontOf = (el: HTMLElement): string => getComputedStyle(el).fontFamily;
+
+const formatValue = (unit: "s" | "ms", value: number): string =>
+ unit === "ms" ? `${formatMs(value)}ms` : formatDuration(value);
+
+/*
+ * EvilCharts' `stripped` bar (evilcharts.com/docs/echarts/bar-chart/static):
+ * a bright cap of constant height at the bar's end, on a body dimmed to a
+ * fifth of the colour. Theirs bakes the cap into a gradient sized from the
+ * bar's pixel length; so does this, with the tool's `var()` colour, which a
+ * gradient stop resolves as a fill does. The axis end is set here, so a
+ * bar's pixel length is known before ECharts lays it out.
+ */
+const CAP = 3;
+const niceMax = (v: number): number => {
+ const p = 10 ** Math.floor(Math.log10(v));
+ return (Math.ceil((v / p) * 2) / 2) * p;
+};
+const stripped = (color: string, value: number, max: number, pixels: number, across: boolean) => {
+ const f = Math.min(0.85, CAP / Math.max(1, (value / max) * pixels));
+ const dim = `color-mix(in oklab, ${color} 22%, transparent)`;
+ return {
+ type: "linear",
+ /* From the bar's end: its right on a horizontal bar, its top on a vertical one. */
+ ...(across ? { x: 1, y: 0, x2: 0, y2: 0 } : { x: 0, y: 0, x2: 0, y2: 1 }),
+ colorStops: [
+ { offset: 0, color },
+ { offset: f, color },
+ { offset: f, color: dim },
+ { offset: 1, color: dim },
+ ],
+ };
+};
+
+/* ------------------------------------------------------------------ */
+/* Bars by tool */
+/* ------------------------------------------------------------------ */
+
+export const BarFigure = ({
+ bars,
+ unit,
+ baseline,
+ reference,
+ xLabel,
+ yLabel,
+ variant = "solid",
+ legend,
+}: {
+ /** Fastest first. Only tools that completed every fixture -- see `meanBars`. */
+ bars: readonly (Named & { readonly value: number })[];
+ /* The UNIT, not a formatter: a function cannot cross into a client island. */
+ unit: "s" | "ms";
+ baseline: string | null;
+ /**
+ * The baseline's value, given rather than looked up in `bars`: the reader
+ * may have hidden the baseline, and the multiples and the rule are still
+ * taken against it.
+ */
+ reference: number | undefined;
+ /** What the bars measure, and what the rows are. */
+ xLabel: string;
+ yLabel: string;
+ variant?: "solid" | "stripped";
+ legend?: ReactNode;
+}) => {
+ const height = bars.length * 44 + 64;
+
+ const ref = useChart(
+ (el) => {
+ if (bars.length === 0) return null;
+ const longest = Math.max(...bars.map((b) => b.label.length));
+ const narrow = el.clientWidth < 640;
+ const left = Math.min(200, longest * 7.2 + 28) + (narrow ? 0 : 22);
+ const right = narrow ? 76 : 170;
+ const max = niceMax(Math.max(...bars.map((b) => b.value), reference ?? 0));
+ const fill = (tool: string, value: number) =>
+ variant === "stripped"
+ ? stripped(toolColor(tool), value, max, el.clientWidth - left - right, true)
+ : toolColor(tool);
+ const inst = echarts.init(el, undefined, { renderer: RENDERER, height });
+ inst.setOption({
+ animationDuration: EVIL_REVEAL,
+ textStyle: { fontFamily: fontOf(el) },
+ grid: { left, right, top: 4, bottom: 52 },
+ xAxis: {
+ type: "value",
+ ...axisTitle(xLabel, 32),
+ /* Zero-based, always. A bar's length is the number. */
+ min: 0,
+ max,
+ axisLine: { show: false },
+ axisTick: EVIL_TICK_DOT,
+ splitLine: EVIL_SPLIT_LINE,
+ axisLabel: {
+ ...EVIL_AXIS_LABEL,
+ hideOverlap: true,
+ formatter: (v: number) => (v === 0 ? "0" : formatValue(unit, v)),
+ },
+ },
+ yAxis: {
+ type: "category",
+ /* Not on a phone: there the row labels are already at the edge. */
+ ...(narrow ? {} : axisTitle(yLabel, left - 14)),
+ inverse: true,
+ data: bars.map((b) => b.label),
+ axisLine: { show: false },
+ axisTick: { ...EVIL_TICK_DOT, alignWithLabel: true },
+ axisLabel: { color: EVIL_INK, fontSize: EVIL_LABEL_TEXT, margin: 12 },
+ },
+ tooltip: { show: false },
+ series: [
+ {
+ type: "bar",
+ barWidth: 18,
+ silent: true,
+ data: bars.map((b) => ({
+ value: b.value,
+ itemStyle:
+ b.tool === baseline
+ ? {
+ /* The baseline: its own colour, as a dashed outline. */
+ color: variant === "stripped" ? fill(b.tool, b.value) : "transparent",
+ borderColor: toolColor(b.tool),
+ borderWidth: 1.5,
+ borderType: [3, 3],
+ borderRadius: 4,
+ }
+ : { color: fill(b.tool, b.value), borderRadius: 4 },
+ })),
+ label: {
+ show: true,
+ position: "right",
+ distance: 10,
+ formatter: (p: { dataIndex: number }) => {
+ const bar = bars[p.dataIndex];
+ if (bar === undefined) return "";
+ const note =
+ narrow || reference === undefined
+ ? ""
+ : bar.tool === baseline
+ ? "baseline"
+ : formatVersus(reference / bar.value);
+ return `{value|${formatValue(unit, bar.value)}} {note|${note}}`;
+ },
+ rich: {
+ value: { color: EVIL_INK, fontSize: EVIL_LABEL_TEXT, fontWeight: 600 },
+ note: { color: EVIL_MUTED, fontSize: EVIL_AXIS_TEXT },
+ },
+ },
+ markLine:
+ /* Only with the baseline's bar on screen. Hidden, its time can
+ lie beyond the axis, and a guide must not describe a rule
+ nobody can see. The multiples are still against it. */
+ reference === undefined || !bars.some((b) => b.tool === baseline)
+ ? undefined
+ : {
+ silent: true,
+ symbol: "none",
+ animation: false,
+ label: { show: false },
+ lineStyle: { color: EVIL_BORDER, type: [3, 3], width: 1 },
+ data: [{ xAxis: reference }],
+ },
+ },
+ ],
+ });
+ return inst;
+ },
+ [bars, unit, baseline, reference, xLabel, yLabel, variant],
+ );
+
+ return (
+
+ {bars.length === 0 ? (
+
No completed runs to draw.
+ ) : (
+
+ )}
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Bars by fixture, grouped */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Every fixture's times in one chart: a group of bars per fixture, a bar per
+ * tool, in the same order in every group, which is the legend's. Colour is
+ * not the only carrier: the order is, and hovering a group lists it by name.
+ * A tool with no time on a fixture -- not run, or a failed run -- has no bar
+ * there, and the hover says which.
+ */
+export const GroupedBarFigure = ({
+ fixtures,
+ tools,
+ unit,
+ yLabel,
+ legend,
+}: {
+ fixtures: readonly {
+ readonly fixture: string;
+ readonly label: string;
+ readonly values: { readonly [tool: string]: number };
+ readonly failed: readonly string[];
+ }[];
+ /** In the order drawn: the leaderboard's. */
+ tools: readonly Named[];
+ unit: "s" | "ms";
+ yLabel: string;
+ legend?: ReactNode;
+}) => {
+ const height = 380;
+ const ref = useChart(
+ (el) => {
+ if (tools.length === 0 || fixtures.length === 0) return null;
+ const name = (tool: string) => tools.find((t) => t.tool === tool)?.label ?? tool;
+ const grid = { left: 64, right: 8, top: 12, bottom: 36 };
+ const max = niceMax(Math.max(...fixtures.flatMap((f) => Object.values(f.values))));
+ const inst = echarts.init(el, undefined, { renderer: RENDERER, height });
+ inst.setOption({
+ animationDuration: EVIL_REVEAL,
+ textStyle: { fontFamily: fontOf(el) },
+ grid,
+ xAxis: {
+ type: "category",
+ data: fixtures.map((f) => f.label),
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: { color: EVIL_INK, fontSize: EVIL_LABEL_TEXT, margin: 14 },
+ },
+ yAxis: {
+ type: "value",
+ ...axisTitle(yLabel, 48),
+ /* Zero-based, always. A bar's length is the number. */
+ min: 0,
+ max,
+ axisLine: { show: false },
+ axisTick: EVIL_TICK_DOT,
+ splitLine: EVIL_SPLIT_LINE,
+ axisLabel: {
+ ...EVIL_AXIS_LABEL,
+ formatter: (v: number) => (v === 0 ? "0" : formatValue(unit, v)),
+ },
+ },
+ tooltip: {
+ trigger: "axis",
+ axisPointer: { type: "none" },
+ /* Inside the chart: its scroll box would clip it. */
+ confine: true,
+ backgroundColor: "var(--popover)",
+ borderColor: "var(--border)",
+ textStyle: { color: "var(--popover-foreground)", fontSize: EVIL_LABEL_TEXT },
+ formatter: (params: { dataIndex: number }[]) => {
+ const f = fixtures[params[0]?.dataIndex ?? -1];
+ if (f === undefined) return "";
+ const rows = Object.entries(f.values)
+ .sort(([, a], [, b]) => a - b)
+ .map(
+ ([tool, v]) =>
+ `
${rows}${failed}`;
+ },
+ },
+ series: tools.map((t) => ({
+ type: "bar",
+ name: t.label,
+ barMaxWidth: 12,
+ barGap: "20%",
+ itemStyle: { borderRadius: [3, 3, 0, 0] },
+ /* ECharts lightens a hovered bar's colour itself, and cannot lighten a `var()`: the bar vanished. */
+ emphasis: { disabled: true },
+ /* No time on a fixture is no bar: "-" is ECharts' empty cell. */
+ data: fixtures.map((f) => {
+ const v = f.values[t.tool];
+ return v === undefined
+ ? "-"
+ : {
+ value: v,
+ itemStyle: {
+ color: stripped(
+ toolColor(t.tool),
+ v,
+ max,
+ height - grid.top - grid.bottom,
+ false,
+ ),
+ },
+ };
+ }),
+ })),
+ });
+ return inst;
+ },
+ [fixtures, tools, unit, yLabel],
+ );
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/apps/www/src/components/cite.tsx b/apps/www/src/components/cite.tsx
new file mode 100644
index 0000000000..9b2d54a676
--- /dev/null
+++ b/apps/www/src/components/cite.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { WithLocation } from "@/components/with-location";
+import { CheckIcon, CircleAlertIcon, CopyIcon } from "lucide-react";
+import { useId, useState, useSyncExternalStore } from "react";
+import { Button } from "@/components/ui/button";
+import { useCopy } from "@/hooks/use-copy";
+import { citation, citeFor, CITE_FORMATS, type CiteFormat } from "@/lib/cite";
+import { isSectionId, SECTION_DEFS } from "@/lib/sections";
+
+const LABELS: { readonly [format in CiteFormat]: string } = {
+ bibtex: "BibTeX",
+ apa: "APA",
+ url: "URL",
+};
+
+const never = () => () => {};
+
+const decode = (path: string) => {
+ try {
+ return decodeURIComponent(path);
+ } catch {
+ return path;
+ }
+};
+
+/**
+ * Cite this page, as the docs' page-aside call-to-action card
+ * (vltpkg/www/docs/src/components/toc.tsx): a title, one muted line, and the
+ * action -- the format as three small toggles beside an outline copy button.
+ * The text itself is not shown; copying it is the point.
+ *
+ * The sidebar is the root layout's and gets no page props, so the citation
+ * is built from the address: the benchmark and scenario from the path, the
+ * run from `date` -- or, with none, the latest run's date -- and as its URL
+ * this page pinned to that date. It claims no counts (`citeFor`). Off a
+ * benchmark's pages (About, Methodology) it cites the page
+ * itself. The origin is the reader's own.
+ */
+export const SidebarCite = ({ latest }: { latest: string | null }) => (
+
+ {({ pathname, query }) => }
+
+);
+
+const Cite = ({ latest, raw, query }: { latest: string | null; raw: string; query: string }) => {
+ const [format, setFormat] = useState("url");
+ const { status, copy } = useCopy(1500);
+ const id = useId();
+ const params = new URLSearchParams(query);
+ const origin = useSyncExternalStore(
+ never,
+ () => window.location.origin,
+ () => "",
+ );
+
+ const [, section, scenario] = decode(raw).split("/");
+ const date = params.get("date") ?? latest;
+ const run =
+ isSectionId(section) && date !== null
+ ? { benchmark: SECTION_DEFS[section].label, scenario, date }
+ : null;
+ const pinned = new URLSearchParams(params);
+ if (run !== null) pinned.set("date", run.date);
+ const url = run === null ? `${origin}${raw}` : `${origin}${raw}?${pinned.toString()}`;
+ const cite = citeFor(run);
+ const text = citation(
+ format,
+ raw === "/methodology" ? { ...cite, title: `${cite.title}: Methodology` } : cite,
+ url,
+ );
+
+ return (
+
+
+ Cite this page
+
+
+ {run === null
+ ? "Cites this page, with a link back here."
+ : "Cites this run by its date, with a link back here."}
+
+
+
+ {CITE_FORMATS.map((value) => (
+
+ ))}
+
+
+
+ {status === "copied"
+ ? "Citation copied"
+ : status === "failed"
+ ? "Could not copy the citation"
+ : ""}
+
+
+
+ );
+};
diff --git a/apps/www/src/components/code-card.tsx b/apps/www/src/components/code-card.tsx
new file mode 100644
index 0000000000..bee734038f
--- /dev/null
+++ b/apps/www/src/components/code-card.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { BracesIcon, CheckIcon, CircleAlertIcon, CopyIcon, TerminalIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { Button } from "@/components/ui/button";
+import { useCopy } from "@/hooks/use-copy";
+import { cn } from "@/lib/utils";
+
+/*
+ * The docs' code card (vltpkg/www/docs/src/components/code-card.tsx), ported for its one-tab form,
+ * which is the only one this site uses. What differs: no package-manager tabs or brand marks, a
+ * failed copy shows on the button (this app's `useCopy` has no toasts), and `actions` sit beside
+ * copy, for the agent prompt's launch menu.
+ */
+
+const shells = new Set(["bash", "sh", "shell", "zsh", "console"]);
+
+// `content` is what the
shows
+export type CodeTab = {
+ code: string;
+ lang?: string;
+ title?: string;
+ content: ReactNode;
+};
+type CodeCardProps = Omit, "children"> & {
+ tabs: [CodeTab];
+ actions?: ReactNode;
+};
+
+// the concave corner where the active tab meets the panel's top edge
+const Corner = () => (
+
+
+
+);
+
+// a tab's label, plus the indicator that flows into the panel
+const TabFace = ({ tab: { lang = "text", title } }: { tab: CodeTab }) => {
+ const terminal = shells.has(lang);
+ const Icon = terminal ? TerminalIcon : BracesIcon;
+ return (
+ <>
+
+ {/* the first tab lines up with the panel's edge, so that side runs straight down */}
+
+
+
+
+
+ {title ?? (terminal ? "Terminal" : lang)}
+
+ >
+ );
+};
+
+export const CodeCard = ({ tabs: [tab], actions, className, ...props }: CodeCardProps) => {
+ const { status, copy } = useCopy(1500);
+
+ return (
+
+
+
+
+
+ {tab.content}
+
+
+ {actions}
+
+
+
+ );
+};
diff --git a/apps/www/src/components/copy-page.tsx b/apps/www/src/components/copy-page.tsx
new file mode 100644
index 0000000000..b6d9b3518b
--- /dev/null
+++ b/apps/www/src/components/copy-page.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useRef } from "react";
+import { WithLocation } from "@/components/with-location";
+import { DropdownMenu } from "radix-ui";
+import { CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, TerminalIcon } from "lucide-react";
+import { Claude } from "@/components/icons/claude";
+import { Markdown } from "@/components/icons/markdown";
+import { OpenAI } from "@/components/icons/openai";
+import { Button } from "@/components/ui/button";
+import { useCopy } from "@/hooks/use-copy";
+
+/*
+ * The docs' Copy Page (vltpkg/www/docs/src/components/copy-page.tsx), ported. What differs: the
+ * markdown twin is this view's, query and all (`/package-managers/clean.md?fixture=astro`), read from
+ * the router so it follows client-side navigation and nuqs' URL updates; and a failure shows on the
+ * button, not in a toast.
+ */
+
+export const item =
+ "text-muted-foreground flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm outline-none data-highlighted:bg-foreground/5 data-highlighted:text-foreground [&_svg]:size-4";
+
+/** Open in Claude, ChatGPT and Claude Code, each started on a prompt. Shared with the agent prompt. */
+export const LaunchItems = ({ prompt, cli = prompt }: { prompt: string; cli?: string }) => {
+ const q = encodeURIComponent(prompt);
+ return (
+ <>
+
+
+
+ Open in Claude
+
+
+
+
+
+ Open in ChatGPT
+
+
+
+
+
+ Open in Claude Code
+
+
+ >
+ );
+};
+
+// prompts stay short and link to the markdown twin: query strings get long fast (claude-cli:// caps q at 5,000 chars).
+// rendered only inside the open menu, so reading the origin never runs on the server
+const AiActions = ({ path }: { path: string }) => {
+ const md = `${window.location.origin}${path}`;
+ return (
+ <>
+
+ {/* claude-cli://open starts in the home directory, so have it ask for the project first */}
+
+ >
+ );
+};
+
+// a split button in the docs' code card's dress: the card is the frame, the copy action its inset panel, its
+// corners the frame's radius less the padding. the view's `.md` twin (see next.config.ts rewrites) is what gets
+// copied, viewed and linked
+export const CopyPage = () => (
+
+ {({ pathname, query }) => }
+
+);
+
+const Copy = ({ pathname, query }: { pathname: string; query: string }) => {
+ const path = `${pathname}.md${query ? `?${query}` : ""}`;
+ const { status, copy } = useCopy(1500);
+ const markdown = useRef<{ path: string; text: Promise }>(null);
+
+ // fetch on hover/focus so the copy only awaits an already-settled promise;
+ // Safari drops clipboard writes that happen too long after the click
+ // a failed fetch is forgotten, so the next hover or click retries instead of reusing it; so is another view's
+ const load = () => {
+ if (markdown.current?.path !== path) {
+ const entry = {
+ path,
+ text: fetch(path).then((res) => {
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
+ return res.text();
+ }),
+ };
+ entry.text.catch(() => {
+ if (markdown.current === entry) markdown.current = null;
+ });
+ markdown.current = entry;
+ }
+ return markdown.current.text;
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/www/src/components/data-table.tsx b/apps/www/src/components/data-table.tsx
new file mode 100644
index 0000000000..4c7cb5e103
--- /dev/null
+++ b/apps/www/src/components/data-table.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
+import { useQueryState } from "nuqs";
+import type { ReactNode } from "react";
+import { reportParams, tableSortOf, withTableSort } from "@/lib/params";
+
+/**
+ * The one table: the docs site's look, the previous app's sorting.
+ *
+ * A header row of muted sans labels in the panel's own colour (a tinted one
+ * would put a band of another shade under the container's tab); hairlines
+ * between rows; generous cells; values in mono. It has no border or
+ * radius of its own: it sits inside the figure container's panel, which is
+ * the frame, and two frames would be one too many. Every column sorts, as the previous app's tables did
+ * (tmp/app/src/components/variation/table.tsx).
+ *
+ * The server shapes everything. Each cell arrives as what to DRAW (`node`,
+ * already rendered) and what to sort BY (`sort`), so this island decides
+ * nothing about the data -- only the order of rows it was given. A cell with
+ * no sort value (a DNF, a tool that did not run) always sorts last, in either
+ * direction: an absent measurement is not the smallest one.
+ *
+ * Sort state is in the URL (`sort`, one entry per table), shallow: it changes
+ * what this island draws and nothing the server computes.
+ */
+export interface Column {
+ readonly id: string;
+ readonly label: ReactNode;
+ readonly align?: "left" | "right";
+ /** Plain-text name for the sort button's accessible label. */
+ readonly name: string;
+}
+
+export interface Row {
+ readonly id: string;
+ readonly cells: {
+ readonly [column: string]: {
+ readonly node: ReactNode;
+ readonly sort: number | string | null;
+ };
+ };
+}
+
+/** One cell against another: numbers numerically, text alphabetically, an absent value always last. */
+export const compare = (
+ a: number | string | null,
+ b: number | string | null,
+ sign: 1 | -1,
+): number => {
+ if (a === null && b === null) return 0;
+ if (a === null) return 1;
+ if (b === null) return -1;
+ if (typeof a === "number" && typeof b === "number") return (a - b) * sign;
+ return String(a).localeCompare(String(b)) * sign;
+};
+
+export const DataTable = ({
+ id,
+ columns,
+ rows,
+}: {
+ /** Names this table in the `sort` parameter. */
+ id: string;
+ columns: readonly Column[];
+ rows: readonly Row[];
+}) => {
+ const [entries, setEntries] = useQueryState("sort", reportParams.sort);
+ const sort = tableSortOf(
+ entries,
+ id,
+ columns.map((c) => c.id),
+ );
+
+ const ordered =
+ sort === null
+ ? rows
+ : [...rows].sort((a, b) =>
+ compare(
+ a.cells[sort.column]?.sort ?? null,
+ b.cells[sort.column]?.sort ?? null,
+ sort.direction === "asc" ? 1 : -1,
+ ),
+ );
+
+ /* Unsorted -> ascending -> descending -> the table's own order again. */
+ const cycle = (column: string) => {
+ const next =
+ sort?.column !== column
+ ? ({ column, direction: "asc" } as const)
+ : sort.direction === "asc"
+ ? ({ column, direction: "desc" } as const)
+ : null;
+ void setEntries(withTableSort(entries, id, next));
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/www/src/components/fixture-menu.tsx b/apps/www/src/components/fixture-menu.tsx
new file mode 100644
index 0000000000..ce0c7b6319
--- /dev/null
+++ b/apps/www/src/components/fixture-menu.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { ChevronDown, FolderCode } from "lucide-react";
+import { useQueryState } from "nuqs";
+import { usePending } from "@/components/report-pending";
+import { FixtureName } from "@/components/tool-name";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { reportParams } from "@/lib/params";
+import { cn } from "@/lib/utils";
+
+/* The selected fixture: the hover surface held, the label at full ink where its siblings are muted. */
+const current = (active: boolean): string =>
+ active ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground";
+
+/**
+ * Which fixture the numbers are for, in the page's first row: the fixtures
+ * the scenario published, or all of them, averaged. `fixture` is a
+ * SERVER parameter, so the write is not shallow: the page re-renders, the
+ * sidebar around it does not. A value the scenario did not publish reads as
+ * all fixtures, as the page reads it.
+ */
+export const FixtureMenu = ({
+ fixtures,
+ className,
+}: {
+ fixtures: readonly string[];
+ className?: string;
+}) => {
+ const { startTransition } = usePending();
+ const [value, setValue] = useQueryState("fixture", {
+ ...reportParams.fixture,
+ shallow: false,
+ startTransition,
+ });
+ const fixture = value !== null && fixtures.includes(value) ? value : null;
+ const options = [null, ...fixtures];
+ return (
+
+
+
+
+
+
+ Fixture
+
+ {options.map((option) => (
+ void setValue(option)}
+ >
+ {option === null ? "All fixtures (mean)" : }
+
+ ))}
+
+
+ );
+};
diff --git a/apps/www/src/components/fixture-trend.tsx b/apps/www/src/components/fixture-trend.tsx
new file mode 100644
index 0000000000..f9c2b9bd44
--- /dev/null
+++ b/apps/www/src/components/fixture-trend.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useState, type ComponentProps } from "react";
+import { TimeSeriesChart } from "@/components/charts-echarts";
+import type { TrendDetail } from "@/components/pinned-run";
+import { FigureTabStrip } from "@/components/tabbed-figure";
+import { FixtureName } from "@/components/tool-name";
+import { fixtureLabel } from "@/lib/tools";
+
+type Rows = ComponentProps["rows"];
+
+/**
+ * Each run as one fixture saw it: the failures there and no others, nothing
+ * partial (one fixture is all or nothing), and that fixture's own spread for
+ * the band.
+ */
+const onFixture = (trend: readonly TrendDetail[], fixture: string): TrendDetail[] =>
+ trend.map((d) => ({
+ ...d,
+ fixtures: d.fixtures.filter((f) => f === fixture),
+ partial: {},
+ failed: Object.fromEntries(
+ Object.entries(d.failed)
+ .map(([tool, list]) => [tool, list.filter((e) => e.fixture === fixture)] as const)
+ .filter(([, list]) => list.length > 0),
+ ),
+ rsd: Object.fromEntries(
+ Object.entries(d.rsdByFixture).flatMap(([tool, by]) =>
+ by[fixture] === undefined ? [] : [[tool, by[fixture]]],
+ ),
+ ),
+ }));
+
+/**
+ * Per-package time on each fixture, run by run: the report's own
+ * "Performance over time" chart, whole -- range, display, pin, brush, value
+ * zoom, legend -- with a tab per fixture where its title tab was. The metric
+ * is per package and has no menu; the chart's own view is kept apart in the
+ * URL (`pp-*`) from the main chart's, and the window, hidden tools,
+ * exclusions and focus are the page's.
+ */
+export const FixtureTimeSeries = ({
+ id,
+ title,
+ fixtures,
+ trend,
+ ...chart
+}: Omit<
+ ComponentProps,
+ "rows" | "yLabel" | "tabs" | "firstTabActive" | "fixedMetric" | "scope"
+> & {
+ fixtures: readonly { readonly fixture: string; readonly rows: Rows }[];
+}) => {
+ const [active, setActive] = useState(fixtures[0]?.fixture ?? "");
+ const shown = fixtures.find((f) => f.fixture === active) ?? fixtures[0];
+ if (shown === undefined) return null;
+ return (
+ ({
+ value: f.fixture,
+ label: ,
+ }))}
+ value={shown.fixture}
+ onChange={setActive}
+ />
+ }
+ />
+ );
+};
diff --git a/apps/www/src/components/fused-chips.tsx b/apps/www/src/components/fused-chips.tsx
new file mode 100644
index 0000000000..0fc5f75b90
--- /dev/null
+++ b/apps/www/src/components/fused-chips.tsx
@@ -0,0 +1,57 @@
+import { useId } from "react";
+import { cn } from "@/lib/utils";
+
+const ROW = "flex flex-wrap gap-x-1 gap-y-2 font-mono text-xs";
+const PILL = "inline-flex h-6 items-center rounded-full px-2.5 whitespace-nowrap";
+
+/**
+ * Tags that read as one group: pills fused by a concave neck where they meet,
+ * like metaballs. Two identical layouts, one over the other. The lower draws
+ * only the fills, through a goo filter (blur, then a hard alpha threshold) that
+ * melts the 4px gaps into necks; the 8px between wrapped rows stays open. The
+ * upper is the list itself, crisp text on no fill, read once by assistive tech.
+ */
+export const FusedChips = ({
+ items,
+ className,
+}: {
+ items: readonly string[];
+ className?: string;
+}) => {
+ // Bare: React's ids carry characters a url() reference can trip on.
+ const filter = `goo${useId().replace(/\W/g, "")}`;
+ return (
+
+
+ {/* Fuse the fills, then outline the fused shape itself: grow it a pixel,
+ paint that the edge colour, and lay the fill back on top. One even
+ hairline round the whole group, necks included. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/www/src/components/navbar/mobile-menu.tsx b/apps/www/src/components/navbar/mobile-menu.tsx
new file mode 100644
index 0000000000..264741527c
--- /dev/null
+++ b/apps/www/src/components/navbar/mobile-menu.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { Fragment, useMemo, useState } from "react";
+import type { ReactNode } from "react";
+import Link from "next/link";
+import {
+ Sheet,
+ SheetTitle,
+ SheetHeader,
+ SheetContent,
+ SheetTrigger,
+ SheetDescription,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Logo } from "./logo";
+import { toSections } from "./types";
+import type { MenuData, MenuItem } from "./types";
+
+interface MobileMenuProps {
+ menuData: MenuData[];
+}
+
+const renderItem = (item: MenuItem, onNavigate: () => void): ReactNode => {
+ const isExternal = item.target === "_blank";
+ const Icon = item.icon;
+
+ return (
+
+ );
+};
+
+// vlt.io's mobile drawer, as a bottom sheet (vaul isn't a dependency here); no auth or socials
+export const MobileMenu = ({ menuData }: MobileMenuProps) => {
+ const [open, setOpen] = useState(false);
+ const close = () => setOpen(false);
+ const sections = useMemo(() => toSections(menuData), [menuData]);
+
+ return (
+
+
+
+
+
+ Mobile menu
+ Site navigation
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/www/src/components/navbar/types.ts b/apps/www/src/components/navbar/types.ts
new file mode 100644
index 0000000000..52de99fc5c
--- /dev/null
+++ b/apps/www/src/components/navbar/types.ts
@@ -0,0 +1,71 @@
+import type { LucideIcon } from "lucide-react";
+import type { ComponentType, HTMLAttributeAnchorTarget } from "react";
+
+/** Lucide SVGs or small raster marks (e.g. OSI logo) used in mega-menu tiles. */
+export type MenuItemIcon = LucideIcon | ComponentType<{ className?: string }>;
+
+export type MenuData = MenuGroup | MenuItem;
+
+export interface MenuGroup {
+ group: string;
+ description?: string;
+ children: (MenuItem | MenuGroup)[];
+ hiddenDesktop?: boolean;
+}
+
+export interface MenuItem {
+ icon?: MenuItemIcon;
+ label: string;
+ path: string;
+ subtitle?: string;
+ blurb?: string;
+ badge?: string;
+ target?: HTMLAttributeAnchorTarget;
+ /** Renders the item as a prominent CTA (outline button) rather than a leaf link. */
+ cta?: boolean;
+ /** Spans all columns in a flat mega-menu grid (e.g. Open Source overview). */
+ featured?: boolean;
+}
+
+export const isGroup = (obj: MenuData): obj is MenuGroup => {
+ return "group" in obj && typeof obj.group === "string";
+};
+
+export const isItem = (obj: MenuData): obj is MenuItem => {
+ return !("group" in obj) && typeof obj.label === "string" && typeof obj.path === "string";
+};
+
+/** Flattened list shape consumed by the mobile menu — each section is a labeled (or unlabeled) block of leaf items. */
+export type Section = {
+ label?: string;
+ items: MenuItem[];
+};
+
+/**
+ * Walks the menu tree and produces a flat list of sections. Nested groups are hoisted into
+ * their own sections (e.g. `Compare` under `Platform` becomes a sibling section). Trailing
+ * standalone items collapse into a single unlabeled section.
+ */
+export const toSections = (data: MenuData[]): Section[] => {
+ const sections: Section[] = [];
+ const standalone: MenuItem[] = [];
+
+ const visit = (node: MenuData) => {
+ if (isGroup(node)) {
+ const items: MenuItem[] = [];
+ const nested: MenuGroup[] = [];
+ for (const child of node.children) {
+ if (isItem(child)) items.push(child);
+ else if (isGroup(child)) nested.push(child);
+ }
+ sections.push({ label: node.group, items });
+ for (const sub of nested) visit(sub);
+ } else if (isItem(node)) {
+ standalone.push(node);
+ }
+ };
+
+ for (const node of data) visit(node);
+ if (standalone.length) sections.push({ items: standalone });
+ return sections;
+};
diff --git a/apps/www/src/components/no-data.tsx b/apps/www/src/components/no-data.tsx
new file mode 100644
index 0000000000..d1ab11e914
--- /dev/null
+++ b/apps/www/src/components/no-data.tsx
@@ -0,0 +1,47 @@
+import { SegmentedLinks } from "@/components/segmented";
+import { SECTION_DEFS, type SectionId } from "@/lib/sections";
+
+/**
+ * The explicit no-data state.
+ *
+ * This exists before any chart does, deliberately. `2026-04-14` publishes 49
+ * benchmark files and **zero** `registry-*` files, while its `chart-data.json`
+ * advertises both registry sections with populated rows for all six fixtures.
+ * A page that enumerated the chart's own section list and then fetched would
+ * render an empty chart on that date and present it as a result.
+ *
+ * So: when the manifest says a section published nothing for a date, say that,
+ * name the date, and offer the dates that did.
+ */
+export const NoData = ({
+ section,
+ date,
+ availableDates,
+}: {
+ section: SectionId;
+ date: string;
+ availableDates: readonly string[];
+}) => (
+
+
+ The run on {date} published nothing for {SECTION_DEFS[section].label.toLowerCase()}. This is a
+ statement about the published data, not a loading state and not a zero. The manifest for{" "}
+ {date} lists no files for this section, so there is nothing to chart. Some dates in the
+ archive advertise sections in their chart data that they did not actually publish — this page
+ trusts the file listing instead.
+
+ )}
+
+);
diff --git a/apps/www/src/components/page-row.tsx b/apps/www/src/components/page-row.tsx
new file mode 100644
index 0000000000..9ba9e65ecd
--- /dev/null
+++ b/apps/www/src/components/page-row.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { animate, LayoutGroup, motion, useMotionValue, useReducedMotion } from "motion/react";
+import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
+
+export const SPRING = { type: "spring", duration: 0.3, bounce: 0 } as const;
+export const FADE = { duration: 0.15, ease: "easeOut" } as const;
+
+/* The cards' column: centred over the content column, not the viewport, so it never sits over the sidebar. */
+const STACK =
+ "pointer-events-none fixed left-4 right-[calc(1rem+var(--lock-gap))] top-[calc(var(--header-height)+0.75rem)] z-40 flex flex-col gap-1 sm:left-0 sm:right-(--lock-gap) sm:items-center sm:px-4 md:left-(--sidebar-width)";
+const CARD = "pointer-events-auto relative sm:max-w-full";
+/* What the chips take besides their own lines, the same in both places: their pt-4 in the page; pt-1 and the card's p-1.5 afloat. */
+const GAP = 16;
+
+/** A card's surface: a layer that fades in under its content as it floats, so the content never stretches. */
+const Surface = ({
+ away,
+ reduced,
+ still,
+ shared,
+}: {
+ away: boolean;
+ reduced: boolean;
+ /** No fade either: the card appears at once. */
+ still: boolean;
+ shared: object;
+}) => (
+
+);
+
+/**
+ * A page's first row -- breadcrumbs on the left, the page's menus on the right
+ * -- and the selection chips under it, which, once the row starts under the
+ * header, become two cards fixed just below it, so the filters stay in reach
+ * down a long report.
+ *
+ * ONE ELEMENT, TWO PLACES: each turns fixed and motion's layout animation
+ * morphs it from its place in the page to its card and back, so the menus are
+ * never remounted and an open one survives the switch, and a chip cleared in
+ * the card is the chip in the page. The children animate position only, so the
+ * text never stretches. One trigger and one LayoutGroup, so the two move as a
+ * set. While they float, the slot keeps the row's height and the chips'.
+ *
+ * THE CHIPS' SPACE IS ONE VALUE: their footprint, 0 without any, springs as
+ * chips come and go and is the chips' height in the page and their share of
+ * the slot while they float, so what follows glides instead of jumping, in
+ * both places, and the switch between them is exact. Afloat, the card fills
+ * it, so it grows and collapses with it, fading and shrinking toward the bar.
+ * Below sm the row's card holds only the menus, and the trail fades back in as
+ * the card returns. z-40: over the content and the charts, under the header
+ * and the menus it opens (z-50). Reduced motion: the cards jump, their
+ * surfaces fade, and the space changes at once.
+ */
+export const PageRow = ({
+ className,
+ trail,
+ menus,
+ chips,
+}: {
+ className: string;
+ trail: ReactNode;
+ menus: ReactNode;
+ chips?: ReactNode;
+}) => {
+ const slot = useRef(null);
+ const row = useRef(null);
+ const list = useRef(null);
+ /** The row's height in the page while it floats; null while it is in the page. */
+ const [spacer, setSpacer] = useState(null);
+ const away = spacer !== null;
+ /** The chips' lines, 0 without any (a leaving chip is popped out of them); null until measured, when the SSR height stands. */
+ const [listHeight, setListHeight] = useState(null);
+ const shown = listHeight !== 0;
+ const footprint = useMotionValue(0);
+ const measured = useRef(false);
+ /** Whether it has floated yet: only then does the trail, hidden in the phone's card, fade back. */
+ const [floated, setFloated] = useState(false);
+ /** The row was jumped past, not scrolled past: a reload's restored scroll, a link to an anchor. */
+ const [jumped, setJumped] = useState(false);
+ const reduced = useReducedMotion() === true;
+
+ /* Next keeps a page just left mounted and hidden, and shows it again on the
+ way back, scrolled elsewhere: it must not paint the card it left with, nor
+ morph from it. Back in the page at once, before paint; the observer below
+ floats it again if it should be. */
+ useLayoutEffect(() => {
+ setSpacer(null);
+ setJumped(true);
+ }, []);
+
+ useEffect(() => {
+ const element = slot.current;
+ if (!element) return;
+ const header = document.querySelector("header")?.getBoundingClientRect().height ?? 56;
+ // Away once the header starts to cover it: the morph begins where the row is still seen.
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry === undefined) return;
+ const covered = entry.intersectionRatio < 1 && entry.boundingClientRect.top < header;
+ const height = row.current?.offsetHeight ?? 0;
+ setSpacer((current) => (covered ? (current ?? height) : null));
+ // Already out of sight, the morph would fly in from off-screen: the card just appears.
+ setJumped(covered && entry.boundingClientRect.bottom < 0);
+ if (covered) setFloated(true);
+ },
+ { rootMargin: `-${header}px 0px 0px 0px`, threshold: [0, 1] },
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const element = list.current;
+ if (!element) return;
+ const observer = new ResizeObserver(() => setListHeight(element.offsetHeight));
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (listHeight === null) return;
+ const target = listHeight === 0 ? 0 : GAP + listHeight;
+ // The first measure only takes over from the SSR height, which it equals.
+ if (!measured.current || reduced) {
+ measured.current = true;
+ footprint.jump(target);
+ return;
+ }
+ const controls = animate(footprint, target, SPRING);
+ return () => controls.stop();
+ }, [footprint, listHeight, reduced]);
+
+ const still = reduced || jumped;
+ const shared = { layoutDependency: away, transition: still ? { duration: 0 } : SPRING };
+ return (
+
+
+ );
+};
diff --git a/apps/www/src/components/range-picker.tsx b/apps/www/src/components/range-picker.tsx
new file mode 100644
index 0000000000..856e3d87f2
--- /dev/null
+++ b/apps/www/src/components/range-picker.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { CalendarDays, ChevronDown } from "lucide-react";
+import { useQueryStates } from "nuqs";
+import { useState } from "react";
+import type { DateRange } from "react-day-picker";
+import { usePending } from "@/components/report-pending";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { MAX_WINDOW_DAYS, RANGES, reportParams } from "@/lib/params";
+
+/*
+ * Dates here are calendar days with no time and no zone. They are built and
+ * read with LOCAL getters on both sides, so a published `2026-10-13` is the
+ * 13th in the calendar wherever the reader is.
+ */
+const toDate = (iso: string): Date => {
+ const [y, m, d] = iso.split("-").map(Number);
+ return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
+};
+const toIso = (date: Date): string =>
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
+
+const words = (from: string, to: string): string => {
+ const a = toDate(from);
+ const b = toDate(to);
+ const short = (d: Date, year: boolean) =>
+ d.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ ...(year ? { year: "numeric" } : {}),
+ });
+ return `${short(a, a.getFullYear() !== b.getFullYear())} – ${short(b, true)}`;
+};
+
+/**
+ * The trend's date range: presets, or an exact start and end from a calendar.
+ *
+ * ONLY PUBLISHED DATES CAN BE CHOSEN. Every other day is disabled -- days
+ * after the run being shown, days before the archive begins, and the days in
+ * between on which nothing ran -- so a range cannot start or end on a hole,
+ * and the archive's gaps (2026-06-17 to 2026-06-29 is eleven days wide) look
+ * like gaps in the calendar. A range may SPAN a hole; the chart draws it as
+ * one.
+ *
+ * This is a server parameter: the window decides which files are fetched. So
+ * the write is not shallow, and the page re-renders with the new data.
+ *
+ * The calendar is react-day-picker's, with its keyboard and screen-reader
+ * behaviour as shipped.
+ */
+export const RangePicker = ({
+ dates,
+ end,
+ from,
+ to,
+ custom,
+}: {
+ /** Every published date, ascending. */
+ dates: readonly string[];
+ /** The run the report is showing; nothing after it can be chosen. */
+ end: string;
+ /** The window in effect, as the server resolved it. */
+ from: string;
+ to: string;
+ custom: boolean;
+}) => {
+ const { startTransition } = usePending();
+ const [params, setParams] = useQueryStates(
+ { range: reportParams.range, from: reportParams.from, to: reportParams.to },
+ { shallow: false, startTransition },
+ );
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState({
+ from: toDate(from),
+ to: toDate(to),
+ });
+
+ const published = new Set(dates.filter((d) => d <= end));
+ const first = dates[0] ?? end;
+ const ready = draft?.from !== undefined && draft.to !== undefined;
+
+ return (
+ {
+ setOpen(next);
+ if (next) setDraft({ from: toDate(from), to: toDate(to) });
+ }}
+ >
+
+ {/* In the figure's top row, one of a pair with the scale menu.
+ Below sm it is the icon alone; the range stays its accessible
+ name and is the first thing in the popover. */}
+
+
+
+
+ Days with no published run cannot be chosen. Up to {MAX_WINDOW_DAYS} days.
+
+
+
+
+
+
+ );
+};
diff --git a/apps/www/src/components/raw-results.tsx b/apps/www/src/components/raw-results.tsx
new file mode 100644
index 0000000000..dc61a02a15
--- /dev/null
+++ b/apps/www/src/components/raw-results.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { Command as CommandPrimitive } from "cmdk";
+import { ChevronDown, ExternalLink, SearchIcon } from "lucide-react";
+import { FixtureName } from "@/components/tool-name";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { fixtureLabel } from "@/lib/tools";
+
+type RawFile = {
+ readonly fixture: string;
+ readonly scenario: string;
+ readonly href: string;
+};
+
+/* Every word typed is in the fixture's or the scenario's name: cmdk's fuzzy
+ default also matched letters scattered through the URL. */
+const matches = (_value: string, search: string, keywords: string[] = []) => {
+ const names = keywords.join(" ").toLowerCase();
+ return search
+ .toLowerCase()
+ .split(/\s+/)
+ .every((word) => names.includes(word))
+ ? 1
+ : 0;
+};
+
+/**
+ * Data and source: the raw result files behind this page, at the end of the
+ * content column -- the very URLs the page fetched to draw itself, so each one
+ * resolves. A filterable list, grouped by fixture, each file named by its
+ * scenario; a scenario's own page has one file per fixture, so there the
+ * fixtures are the list. Each row is a real link (open in a new tab, copy its
+ * address); Enter opens the highlighted one.
+ */
+export const DataAndSource = ({ raw = [] }: { raw?: readonly RawFile[] }) => {
+ if (raw.length === 0) return null;
+ const groups = new Map();
+ for (const file of raw) groups.set(file.fixture, [...(groups.get(file.fixture) ?? []), file]);
+ const grouped = [...groups.values()].some((files) => files.length > 1);
+ const item = (file: RawFile, label: ReactNode) => (
+ window.open(file.href, "_blank", "noopener,noreferrer")}
+ className="min-h-8 p-0 [&>svg:last-child]:hidden"
+ >
+ {/* the link handles a pointer itself, so a click opens one tab, not two */}
+ e.stopPropagation()}
+ className="flex min-h-8 flex-1 items-center gap-2 px-2"
+ >
+ {label}
+ (JSON, opens in a new tab)
+
+
+
+ );
+ return (
+
+ );
+};
diff --git a/apps/www/src/components/report-pending.tsx b/apps/www/src/components/report-pending.tsx
new file mode 100644
index 0000000000..49544778e6
--- /dev/null
+++ b/apps/www/src/components/report-pending.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ createContext,
+ startTransition,
+ use,
+ useTransition,
+ type ComponentProps,
+ type ReactNode,
+ type TransitionStartFunction,
+} from "react";
+
+/**
+ * Whether the server is re-rendering the report for a changed SERVER
+ * parameter. Every writer of one starts its navigation in one of these two
+ * transitions, so Next keeps the page on screen meanwhile instead of
+ * suspending it, and the report says it is stale.
+ *
+ * Two, because a tools change is not a data change: the trend redraws its
+ * lines from `hide` at once, so only the tables and bars are stale. Anything
+ * else (fixture, window, metric, exclusions, date) changes what the trend
+ * plots too.
+ *
+ * Outside a report the transitions are plain ones and nothing is pending.
+ */
+interface Pending {
+ readonly data: boolean;
+ readonly tools: boolean;
+ readonly startTransition: TransitionStartFunction;
+ readonly startToolsTransition: TransitionStartFunction;
+}
+
+const PendingContext = createContext({
+ data: false,
+ tools: false,
+ startTransition,
+ startToolsTransition: startTransition,
+});
+
+export const usePending = (): Pending => use(PendingContext);
+
+export const PendingProvider = ({ children }: { children: ReactNode }) => {
+ const [data, startData] = useTransition();
+ const [tools, startTools] = useTransition();
+ return (
+
+ {children}
+
+ );
+};
+
+/** The part of the report the server shapes: dimmed while it is stale, never blanked. */
+export const PendingRegion = ({ children }: { children: ReactNode }) => {
+ const { data, tools } = usePending();
+ return (
+
+
+ {data || tools ? "Loading…" : ""}
+
+ {children}
+
+ );
+};
+
+/** A link that changes only the query: the page stays, marked stale, until the new one is ready. */
+export const PendingLink = ({ href, ...props }: ComponentProps & { href: string }) => {
+ const router = useRouter();
+ const { startTransition: start } = usePending();
+ return (
+ {
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
+ return;
+ event.preventDefault();
+ start(() => router.push(href, { scroll: false }));
+ }}
+ />
+ );
+};
diff --git a/apps/www/src/components/report-shell.tsx b/apps/www/src/components/report-shell.tsx
new file mode 100644
index 0000000000..ffc4b2597c
--- /dev/null
+++ b/apps/www/src/components/report-shell.tsx
@@ -0,0 +1,309 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+import { TrendSkeleton } from "@/components/charts-echarts";
+import { CopyPage } from "@/components/copy-page";
+import { FusedChips } from "@/components/fused-chips";
+import { Marked } from "@/components/inline-code";
+import { PageRow } from "@/components/page-row";
+import { Figure, ReportSection } from "@/components/report";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { Skeleton } from "@/components/ui/skeleton";
+import { AVERAGE, DESCRIPTIONS, scenariosFor, stateParts } from "@/lib/scenarios";
+import { SECTION_DEFS, type SectionId } from "@/lib/sections";
+import { BASELINE, toolLabel } from "@/lib/tools";
+
+/* The first row: as tall as the sidebar's search button (h-9) and level with it. Wraps on a phone. */
+export const TOP = "flex min-h-9 flex-wrap items-center justify-between gap-x-6 gap-y-3";
+
+/* The docs' page title: their typeset h1 (typeset.css), so the page sets `typeset typeset-docs` around it. */
+export const TITLE = "min-w-0 [overflow-wrap:anywhere]";
+
+/* The finding: the docs' heading weight and tracking, a step under their hero. */
+export const FINDING =
+ "max-w-[30ch] text-3xl font-semibold tracking-tight text-balance sm:text-4xl";
+
+/* The docs' description line, under an article's h1. */
+export const DESCRIPTION = "text-base leading-[1.6] text-pretty text-muted-foreground";
+
+/* Below sm the two menus share the row's width; above it each keeps a fixed width. */
+export const MENUS = "flex w-full flex-row items-center gap-2 sm:w-auto";
+export const TOOLS_MENU = "min-w-0 flex-1 sm:w-60 sm:flex-none";
+export const FIXTURE_MENU = "min-w-0 flex-1 sm:w-40 sm:flex-none";
+
+/** A variation name with a break opportunity after each `+`, so a compound name wraps at its joins. */
+const ScenarioName = ({ variation }: { variation: string }) =>
+ variation.split("+").map((part, index) => (
+
+ {index === 0 ? null : (
+ <>
+ +
+ >
+ )}
+ {part}
+
+ ));
+
+/** The docs' breadcrumb trail, as their pages set it: Benchmarks / the benchmark / the scenario. */
+export const Trail = ({
+ label,
+ sectionHref,
+ page,
+}: {
+ label: string;
+ sectionHref: string;
+ /** Absent on the benchmark's own page, where the trail ends at the benchmark. */
+ page?: string;
+}) => (
+
+
+
+
+ Benchmarks
+
+
+ /
+
+ {page === undefined ? (
+ {label}
+ ) : (
+
+ {label}
+
+ )}
+
+ {page === undefined ? null : (
+ <>
+ /
+
+ {page}
+
+ >
+ )}
+
+
+);
+
+const scenarioOf = (section: SectionId, variation: string | undefined) =>
+ variation === AVERAGE
+ ? { variation, uncurated: false, kind: "other" as const, state: undefined }
+ : variation === undefined
+ ? undefined
+ : scenariosFor(section, [variation])[0];
+
+/**
+ * A loading stand-in for text: the text's own lines, filled, so it wraps as
+ * the real text will. The fill is a band down each line's middle, so the
+ * lines stay apart.
+ */
+const GHOST =
+ "rounded-[0.4em] bg-[linear-gradient(var(--color-muted),var(--color-muted))] bg-[length:100%_70%] bg-center bg-no-repeat text-transparent box-decoration-clone animate-pulse motion-reduce:animate-none [&_code]:border-transparent [&_code]:bg-transparent";
+
+/**
+ * Everything above the finding, from the address alone: the trail, the
+ * menus and the selection. It needs no data, so it is
+ * drawn at once -- around a skeleton while the report loads, and the same
+ * again around the report -- and nothing moves when it arrives.
+ */
+export const ReportHead = ({
+ section,
+ variation,
+ sectionHref,
+ menus,
+ chips,
+}: {
+ section: SectionId;
+ /** Absent, or not one of this benchmark's: the trail ends at the benchmark. */
+ variation: string | undefined;
+ sectionHref: string;
+ menus: ReactNode;
+ chips?: ReactNode;
+}) => {
+ const scenario = scenarioOf(section, variation);
+ return (
+ <>
+ {/* The page's first row, level with the sidebar's search: where it is, and what it is narrowed to;
+ under it, each other non-default choice, one click from undone. */}
+
+ }
+ menus={menus}
+ chips={chips}
+ />
+ >
+ );
+};
+
+/**
+ * The scenario's badge, the finding as the page's headline, what was on
+ * disk, and the description running straight into the lede. Without `finding` it
+ * is its own skeleton -- the static parts drawn, the rest a ghost of the
+ * sentence it waits for, so the tags and description do not move on arrival.
+ */
+export const ReportIntro = ({
+ section,
+ variation,
+ averaged,
+ finding,
+ lede,
+ action,
+}: {
+ section: SectionId;
+ variation: string | undefined;
+ /** On the derived view, how many scenarios it is the mean of. */
+ averaged?: number;
+ /** Names between backticks. Absent while the report loads. */
+ finding?: string;
+ lede?: ReactNode;
+ /** A call to action under the lede: on a past run, the way to the latest. */
+ action?: ReactNode;
+}) => {
+ const scenario = scenarioOf(section, variation);
+ const tags =
+ scenario === undefined
+ ? []
+ : scenario.state === undefined
+ ? ["derived", `mean of ${averaged ?? "…"} scenarios`]
+ : scenario.kind === "other"
+ ? []
+ : stateParts(scenario.state).map((part) => `${part.name} ${part.word}`);
+ const baseline = `\`${toolLabel(BASELINE, section)}\``;
+ return (
+ <>
+ {/* Above sm, Copy Page sits beside the headline; below, it follows it. */}
+
+ {scenario === undefined ? null : (
+ /* The scenario, then what it was run on, as one row of the same pills:
+ the name on its own, the tags fused into a group beside it. */
+
+
+
+
+ {tags.length === 0 ? null : }
+
+ )}
+ {finding === undefined ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+ {/* Below sm, under the headline: beside the pills it squeezed long scenario names. */}
+
+
+
+
+ {/* What the scenario is, under the headline; then, on its own, what the
+ numbers are of: the run, the fixtures, the runners. */}
+ {scenario === undefined ? null : (
+
+ {DESCRIPTIONS[scenario.variation] ??
+ "This scenario is published but has no description yet."}
+ {scenario.uncurated ? (
+
+ {" "}
+ Published in this run, and not yet in any curated ordering.
+
+ ) : null}
+
+ )}
+
+ {lede ?? (
+
+ Mean time across 6 fixtures, from the run of Jan 1, 2026 on GitHub-hosted
+ ubuntu-24.04-arm runners.
+
+ )}
+
+ {action === undefined ? null :
{action}
}
+ >
+ );
+};
+
+/**
+ * The report while it loads: its head, whole, and the body's shape -- the
+ * finding, the four numbers, the leaderboard and the trend -- in skeleton.
+ */
+export const ReportSkeleton = ({
+ section,
+ variation,
+ sectionHref,
+}: {
+ section: SectionId;
+ variation: string | undefined;
+ sectionHref: string;
+}) => (
+ <>
+
+
+
+
+ }
+ />
+
+
+
+ Loading…
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+ {[0, 1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/apps/www/src/components/report.tsx b/apps/www/src/components/report.tsx
new file mode 100644
index 0000000000..f35b5573a1
--- /dev/null
+++ b/apps/www/src/components/report.tsx
@@ -0,0 +1,318 @@
+import { BarChart3, Info, LineChart, ScatterChart, Table2 } from "lucide-react";
+import type { ReactNode } from "react";
+import { HeadingAnchor, HeadingLink } from "@/components/heading-anchor";
+
+/**
+ * The report furniture: sections, the one container every figure and table
+ * sits in, and stat tiles.
+ *
+ * Two levels and no more. A section is an h2 and each figure or table in it
+ * an h3 -- unless the section holds a single table, in which case the table's
+ * title IS the section's h2. Nothing is said twice: there is no section
+ * summary and no subtitle under a title; a section's own h2 links to itself.
+ *
+ * There is no margin column and no numbering. Every section and figure keeps
+ * its `id`, and its title links to it, so a deep link to a figure works.
+ * Hovering a title shows `HeadingAnchor`, a copy-link icon in the margin.
+ */
+const ANCHOR = "scroll-mt-[calc(var(--header-height)+1.5rem)]";
+
+export const ReportSection = ({
+ id,
+ title,
+ children,
+}: {
+ id: string;
+ /**
+ * Omitted for a section that holds one table: the table's own title is then
+ * the section's heading, so the same thing is not said twice in a stack.
+ */
+ title?: string;
+ children: ReactNode;
+}) => (
+
+ {title === undefined ? null : (
+
+
+ {title}
+
+
+
+ )}
+
{children}
+
+);
+
+const ICONS = {
+ line: LineChart,
+ bar: BarChart3,
+ dots: ScatterChart,
+ table: Table2,
+} as const;
+
+export type FigureKind = keyof typeof ICONS;
+
+/* The concave corner where the tab meets the panel's top edge: a square in
+ the panel's colour with a shell-coloured, rounded, bordered square inside
+ it. Ported from the docs' code card. A wrong token here shows as a notch. */
+const Corner = () => (
+
+
+
+);
+
+/**
+ * The container every figure and every table sits in: the docs site's code
+ * card (vltpkg/www/docs/src/components/code-card.tsx), ported -- structure
+ * and values -- rather than approximated.
+ *
+ * the shell a `rounded-2xl` card a shade off the page, ringed with a
+ * hairline, holding everything with a 4px inset
+ * the tab top left, in the PANEL's colour and border, open at the
+ * bottom so it flows into the panel, with the concave corner
+ * where its right side meets the panel's top edge
+ * the action top right of the shell, outside the panel: the figure's
+ * own controls, where the code card has copy
+ * the panel `rounded-xl`, bordered, in the page's own colour, holding the
+ * content
+ *
+ * ON A CHART, THE TAB IS THE TITLE. It carries an icon for the kind of chart
+ * and the figure's name, and that name is the heading -- a real one, for the
+ * document outline -- and the link to the figure's own anchor. To its right,
+ * in the same row: the figure's controls (`actions`). Copy-link is the
+ * hover icon (`HeadingAnchor`), which takes the kind icon's place.
+ *
+ * A TABLE HAS NO TAB. The shell and the inset panel are the same -- same
+ * radii, border and shadow, so a table and a chart are one family -- but the
+ * panel's top edge is plain. The table's title is a heading above the
+ * container, with the hover copy-link in the margin to its left.
+ *
+ * Either way there is exactly one visible title, and the reading guide and
+ * notes sit below the container.
+ *
+ * The panel has no padding of its own: a table runs to its edges, and a
+ * chart's rows (`ChartFrame`) bring theirs.
+ *
+ * TWO THINGS THE TAB DEPENDS ON, both easy to break:
+ *
+ * - The panel must NOT be positioned. The tab's face is absolute and reaches
+ * one pixel below the tab row, over the panel's top border, which is what
+ * makes the tab open into the panel. A positioned panel, coming later in
+ * the document, paints over that pixel and the tab gets a bottom border --
+ * which is exactly what `relative` on the panel did.
+ * - Whatever is first inside the panel must be the panel's own colour, or the
+ * tab opens onto a band of a different shade. So the table's header row is
+ * not tinted.
+ *
+ * The docs card squares off the panel's top-left corner with a filler that
+ * overhangs 16px into the panel, which is fine over a code block's padding
+ * and would sit on top of a table header here. The panel's top-left corner is
+ * simply not rounded instead; the tab's left edge runs straight into it.
+ */
+export const Figure = ({
+ id,
+ kind,
+ title,
+ guide,
+ notes = [],
+ actions,
+ level = 3,
+ titlePrefix,
+ tabLead,
+ footer,
+ live = false,
+ tabs,
+ firstTabActive = true,
+ children,
+}: {
+ id: string;
+ /** What this is. A table has no tab; every chart does. */
+ kind: FigureKind;
+ title: string;
+ /** How to read it: what the marks and their encodings mean. */
+ guide?: ReactNode;
+ /** Limits and caveats that belong to this figure and no other. */
+ notes?: readonly Note[];
+ /**
+ * Controls for the top row, right of the tab and before the copy-link
+ * button. Any figure may place controls here; the time series puts its date
+ * range and scale in it.
+ */
+ actions?: ReactNode;
+ /** 2 when the figure is the only thing in its section and heads it. */
+ level?: 2 | 3;
+ /** Read before the title by assistive technology, not shown: what the tab names. */
+ titlePrefix?: string;
+ /** Shown in the tab before the title, e.g. what the title belongs to. */
+ tabLead?: ReactNode;
+ /**
+ * A row under the panel, inside the shell, the tab row's counterpart: e.g.
+ * summary chips on the left and a call to action on the right.
+ */
+ footer?: ReactNode;
+ /** Redraws from the URL itself, so a pending report never marks it stale. */
+ live?: boolean;
+ /**
+ * A tab per view (`FigureTabStrip`) in place of the one title tab: the
+ * title is then a heading above the container, as a table's is.
+ */
+ tabs?: ReactNode;
+ /** With `tabs`: the first is active, so the panel's corner under it is square. */
+ firstTabActive?: boolean;
+ children: ReactNode;
+}) => {
+ const Icon = ICONS[kind];
+ const Heading = level === 2 ? "h2" : "h3";
+ const link = (
+
+ {titlePrefix === undefined ? null : {titlePrefix} }
+ {title}
+
+ );
+ return (
+
+ {kind === "table" || tabs !== undefined ? (
+ /* A table has no tab, and tabs name views: the title is a heading above the container. */
+
+ {kind === "table" ? null : }
+ {link}
+
+
+ ) : null}
+
+
+ {kind === "table" ? null : (
+
+ {tabs !== undefined ? (
+
{tabs}
+ ) : (
+
+
+
+
+ {/* On hover the copy-link icon takes the kind icon's place. */}
+
+
+ {tabLead}
+ {link}
+
+
+
+ )}
+ {/* The figure's own controls, clear of the concave corner by the
+ row's gap. */}
+
+ {actions}
+
+
+ )}
+
+ {children}
+
+ {footer === undefined ? null : (
+ /* Inset as the panel is (the shell's 4px), so a control in a corner is
+ concentric with it: the shell's 16px less 4px is the controls' 12px. */
+
{footer}
+ )}
+
+
+ {guide === undefined ? null : (
+
{guide}
+ )}
+ {notes.map((note) => (
+
+ {note.body}
+
+ ))}
+
+ );
+};
+
+/** A caveat: what it is about, in a line, then what it means. */
+export interface Note {
+ readonly title: string;
+ readonly body: ReactNode;
+}
+
+/**
+ * A caveat set apart from the prose: a hairline card a shade off the page,
+ * an info mark, a short title at full ink and the explanation beneath it.
+ * Quiet, but read at a glance -- a muted paragraph behind a rule was not.
+ */
+export const Callout = ({ title, children }: { title: string; children: ReactNode }) => (
+
+
+
+
{title}
+
{children}
+
+
+);
+
+/**
+ * One number, in the container every figure sits in: the shell a shade off
+ * the page with its hairline ring, and the panel inside it. One element, so
+ * it can stay a `dl` group: the shell is its border, the panel's hairline an
+ * inset ring. A mono label at the top, then space, then the value and the
+ * sentence saying what it means.
+ */
+export const Stat = ({
+ label,
+ value,
+ detail,
+}: {
+ label: string;
+ value: ReactNode;
+ detail: ReactNode;
+}) => (
+ /* Label, value and sentence are three rows of the parent grid (subgrid), so
+ every tile in a row shares them: the values sit on one line however many
+ lines a neighbour's sentence wraps to. The parent must be a grid. */
+
+
{label}
+
{value}
+
{detail}
+
+);
+
+/**
+ * A chart's rows inside the panel: the plot, then the legend.
+ *
+ * No frame of its own -- the panel around it (`Figure`) is the frame -- and
+ * no controls row: a chart's controls are in the container's top row, beside
+ * the tab. The legend, when a chart needs one, is last, always, and is a
+ * bordered grid that meets the panel's edges and draws its own top border.
+ */
+export const ChartFrame = ({ legend, children }: { legend?: ReactNode; children: ReactNode }) => (
+ <>
+
{children}
+ {legend}
+ >
+);
diff --git a/apps/www/src/components/rule.tsx b/apps/www/src/components/rule.tsx
new file mode 100644
index 0000000000..e5b98a5d70
--- /dev/null
+++ b/apps/www/src/components/rule.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+// an etched divider: a dark line over a lighter one, so it reads as a groove in the surface.
+// both tones sit off the page colour, since a line can't be lighter than white or darker than black
+export const Rule = ({ className, ...props }: React.ComponentProps<"hr">) => (
+
+);
diff --git a/apps/www/src/components/scroll-to-top.tsx b/apps/www/src/components/scroll-to-top.tsx
new file mode 100644
index 0000000000..300c592dc1
--- /dev/null
+++ b/apps/www/src/components/scroll-to-top.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { Button } from "@/components/ui/button";
+import { useScrolled } from "@/hooks/use-scrolled";
+
+const MotionButton = motion.create(Button);
+
+/* The docs' motion, mirrored: it rises from below rather than dropping from above. */
+const FULL = {
+ initial: { opacity: 0, filter: "blur(4px)", scale: 0.95, y: 4 },
+ animate: { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 },
+ exit: { opacity: 0, filter: "blur(4px)", scale: 0.95, y: 4 },
+};
+const FADE = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };
+
+/**
+ * The vlt docs' back-to-top button (vltpkg/www/docs/src/components/scroll-to-top.tsx)
+ * and its `useScrolled` rule (past 300px), at the BOTTOM of the screen: the
+ * top has the header and the page's floating breadcrumb card. Centred over
+ * the content column, not the sidebar; z-40, over the bottom page fade (z-30)
+ * and under menus. Rendered once, by the root layout.
+ *
+ * Changed from the docs: the docs' `font-pixel-grid` class names no font there
+ * either, so the label renders in the inherited Inter, as it does here; and
+ * reduced motion keeps only the fade.
+ */
+export const ScrollToTop = () => {
+ const { scrolled } = useScrolled();
+ const reduced = useReducedMotion();
+ return (
+
+ {scrolled ? (
+ window.scrollTo({ top: 0, behavior: "smooth" })}
+ {...(reduced === true ? FADE : FULL)}
+ >
+ back to top
+
+ ) : null}
+
+ );
+};
diff --git a/apps/www/src/components/search.tsx b/apps/www/src/components/search.tsx
new file mode 100644
index 0000000000..6e1224f801
--- /dev/null
+++ b/apps/www/src/components/search.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+import { useRouter } from "next/navigation";
+import { Command as CommandPrimitive } from "cmdk";
+import {
+ FileTextIcon,
+ FolderIcon,
+ HashIcon,
+ LoaderCircleIcon,
+ SearchIcon,
+ TextIcon,
+} from "lucide-react";
+import type * as PageTree from "@/lib/page-tree";
+import { cn } from "@/lib/utils";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
+import { useSidebar } from "@/components/ui/sidebar";
+
+/*
+ * The vlt docs' search (vltpkg/www/docs/src/components/search.tsx), ported as
+ * it stands. The docs query fumadocs' server index of page content; this site
+ * has a dozen pages a benchmark, so the sidebar's own tree is searched in the
+ * browser instead -- every typed word must appear in the page's name or its
+ * benchmark's -- and served in the docs' result shape. Navigation carries the
+ * page's query, as the sidebar's links do.
+ */
+type SortedResult = {
+ id: string;
+ type: "page" | "heading" | "text";
+ content: string;
+ url: string;
+ breadcrumbs?: string[];
+};
+
+const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+/* Every page, each with its benchmark's name (none for a top-level page): the benchmark's own page, then its scenarios at any depth. */
+const pagesOf = (tree: PageTree.Root) => {
+ const within = (
+ nodes: readonly PageTree.Node[],
+ folder: string,
+ ): { page: PageTree.Item; folder: string }[] =>
+ nodes.flatMap((child) =>
+ child.type === "page"
+ ? [{ page: child, folder }]
+ : child.type === "folder"
+ ? within(child.children, folder)
+ : [],
+ );
+ return tree.children.flatMap((node) =>
+ node.type === "page"
+ ? [{ page: node, folder: "" }]
+ : node.type === "folder"
+ ? within([...(node.index ? [node.index] : []), ...node.children], node.name)
+ : [],
+ );
+};
+
+const useDocsSearch = ({ tree }: { tree: PageTree.Root }) => {
+ const [search, setSearch] = useState("");
+ const data = useMemo((): SortedResult[] | "empty" => {
+ const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
+ if (!terms.length) return "empty";
+ const marks = new RegExp(`(${terms.map(escape).join("|")})`, "gi");
+ return pagesOf(tree)
+ .filter(({ page, folder }) =>
+ terms.every((t) => `${folder} ${page.name}`.toLowerCase().includes(t)),
+ )
+ .map(({ page, folder }) => ({
+ id: page.url,
+ type: "page",
+ content: page.name.replace(marks, "$1"),
+ url: page.url,
+ // a benchmark's own page is named for it; only its scenarios need the benchmark beside them
+ breadcrumbs: folder === "" || page.name === folder ? [] : [folder],
+ }));
+ }, [search, tree]);
+ return {
+ search,
+ setSearch,
+ query: { data, isLoading: false, error: null },
+ };
+};
+
+const icons = {
+ page: FileTextIcon,
+ heading: HashIcon,
+ text: TextIcon,
+};
+const MAX_PAGES = 10;
+
+// the server returns results page-first, with that page's heading/text hits right after it
+const byPage = (results: SortedResult[]) =>
+ results.reduce((groups, result) => {
+ if (result.type === "page" || !groups.length) groups.push([result]);
+ else groups.at(-1)?.push(result);
+ return groups;
+ }, []);
+
+// content is markdown with around matches; render as text, never as html
+const Highlighted = ({ text }: { text: string }) =>
+ text
+ .replace(/`|\*\*/g, "")
+ .split(/(.*?)<\/mark>/g)
+ .map((part, i) =>
+ i % 2 ? (
+
+ {part}
+
+ ) : (
+ part
+ ),
+ );
+
+// server renders the Mac glyph; other platforms swap to Ctrl after hydration
+const useIsMac = () =>
+ useSyncExternalStore(
+ () => () => {},
+ () => /Mac|iPhone|iPad/.test(navigator.userAgent),
+ () => true,
+ );
+
+export const SearchTrigger = ({ className, ...props }: React.ComponentProps<"button">) => {
+ const mac = useIsMac();
+ return (
+
+ );
+};
+
+export type Suggestion = {
+ name: React.ReactNode;
+ url: string;
+ folder?: boolean;
+};
+export type SuggestionGroup = {
+ name?: React.ReactNode;
+ links: Suggestion[];
+};
+
+type SearchDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ returnFocus: React.RefObject;
+ // shown before anything is typed
+ suggestions: SuggestionGroup[];
+ tree: PageTree.Root;
+};
+
+export const SearchDialog = ({
+ open,
+ onOpenChange,
+ returnFocus,
+ suggestions,
+ tree,
+}: SearchDialogProps) => {
+ const router = useRouter();
+ const { setOpenMobile } = useSidebar();
+ const { search, setSearch, query } = useDocsSearch({ tree });
+ const [selected, setSelected] = useState("");
+ const navigated = useRef(false);
+ const [shown, setShown] = useState(query.data);
+
+ // results arrive after the keystroke, so cmdk can't auto-select them; select the top hit ourselves
+ if (query.data !== shown) {
+ setShown(query.data);
+ setSelected(Array.isArray(query.data) ? (query.data[0]?.id ?? "") : "");
+ }
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ onOpenChange(!open);
+ }
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [open, onOpenChange]);
+
+ const go = (url: string) => {
+ navigated.current = true;
+ // the page's choices (fixture, hidden tools, window, ...) carry over, as the sidebar's links do
+ router.push(`${url}${window.location.search}`);
+ onOpenChange(false);
+ setOpenMobile(false);
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/www/src/components/sections.tsx b/apps/www/src/components/sections.tsx
new file mode 100644
index 0000000000..dccc37d39f
--- /dev/null
+++ b/apps/www/src/components/sections.tsx
@@ -0,0 +1,442 @@
+import type { TrendPoint } from "@/lib/load";
+import { ratioTo, type CountRow, type FixtureRow, type ToolMean } from "@/lib/measurements";
+import { toolColor } from "@/lib/chart-model";
+import type { LeaderboardRow } from "@/lib/report";
+import { windowChange } from "@/lib/figures";
+import { formatDuration, formatVersus } from "@/lib/findings";
+import type { SectionId } from "@/lib/sections";
+import { fixtureLabel, toolLabel } from "@/lib/tools";
+import { DataTable, type Column, type Row } from "@/components/data-table";
+import { FixtureName, ToolName } from "@/components/tool-name";
+import { SparklineECharts } from "./charts-echarts-dots";
+
+/*
+ * The tables' contents, shaped here on the server and handed to the
+ * `DataTable` island as cells: what to draw, and what to sort by. Everything
+ * honest about a cell -- that a DNF is not a time, that an unpublished count
+ * is not a zero -- is decided in this file; the island only orders rows.
+ *
+ * A tool is named the same way in every table: its brand colour as a swatch
+ * (the colour its line and bar are drawn in), its mark, its name.
+ */
+const MONO = "font-mono text-[0.8125rem]";
+const SUB = "mt-0.5 block text-xs text-muted-foreground";
+
+const Tool = ({ tool, section }: { tool: string; section: SectionId }) => (
+
+
+
+
+);
+
+/** A measurement that is absent, rendered as absent rather than as a zero. */
+const Absent = ({ children = "—", title }: { children?: string; title?: string }) => (
+
+ {children}
+
+);
+
+const toolColumn: Column = { id: "tool", label: "Tool", name: "tool" };
+
+/* ------------------------------------------------------------------ */
+/* The ranking */
+/* ------------------------------------------------------------------ */
+
+export const Leaderboard = ({
+ section,
+ rows: leaderboard,
+ runs,
+ baseline,
+ fixtureCount,
+ trendEndsHere,
+ hidden,
+}: {
+ section: SectionId;
+ rows: readonly LeaderboardRow[];
+ /** How many runs the trend window holds. */
+ runs: number;
+ baseline: string | null;
+ fixtureCount: number;
+ /**
+ * Whether the trend window ends at the run this table is about. With an
+ * exact date range it may not, and "since last run" is then absent.
+ */
+ trendEndsHere: boolean;
+ /**
+ * Tools the reader has hidden. Their rows are left out; nothing else moves.
+ * A row keeps its rank among ALL tools, and ratios are still taken against
+ * the real baseline whether or not its row is showing.
+ */
+ hidden: readonly string[];
+}) => {
+ const widest = Math.max(...leaderboard.map((m) => m.mean), 0) || 1;
+ const against = baseline === null ? "baseline" : toolLabel(baseline, section);
+
+ const columns: Column[] = [
+ { id: "rank", label: "#", name: "rank" },
+ toolColumn,
+ { id: "mean", label: "Mean", name: "mean time", align: "right" },
+ { id: "bar", label: "", name: "mean time" },
+ { id: "versus", label: `Against ${against}`, name: "ratio to the baseline", align: "right" },
+ { id: "delta", label: "Since last run", name: "change since the last run", align: "right" },
+ { id: "trend", label: `${runs} runs in range`, name: "mean time" },
+ ];
+
+ const rows: Row[] = leaderboard.flatMap((m): Row[] => {
+ if (hidden.includes(m.tool)) return [];
+ const { ratio, isBaseline, series } = m;
+ const pct = m.sinceLastRun === null ? null : m.sinceLastRun * 100;
+ const i = m.rank - 1;
+
+ return [
+ {
+ id: m.tool,
+ cells: {
+ rank: { sort: i, node: {i + 1} },
+ tool: {
+ sort: toolLabel(m.tool, section),
+ node: (
+ <>
+
+ {m.version === null ? null : (
+ {m.version}
+ )}
+ {m.failed || m.fixtures < fixtureCount ? (
+
+ {m.fixtures}/{fixtureCount} fixtures
+
+ ) : null}
+ >
+ ),
+ },
+ mean: {
+ sort: m.mean,
+ node: (
+
+ {formatDuration(m.mean)}
+
+ ±{(m.rsd * 100).toFixed(1)}%
+
+
+ ),
+ },
+ bar: {
+ sort: m.mean,
+ node: (
+
+
+
+ ),
+ },
+ versus: {
+ sort: isBaseline ? 1 : ratio,
+ node: (
+
+ {isBaseline ? (
+ baseline
+ ) : ratio === null ? (
+
+ ) : (
+ formatVersus(ratio)
+ )}
+
+ ),
+ },
+ delta: {
+ sort: pct,
+ node: (
+
+ {pct === null ? (
+
+ ) : Math.abs(pct) < 0.05 ? (
+ 0.0%
+ ) : (
+
+ {pct < 0 ? "−" : "+"}
+ {Math.abs(pct).toFixed(1)}%
+
+ )}
+
+ ),
+ },
+ trend: {
+ sort: m.mean,
+ node: ,
+ },
+ },
+ },
+ ];
+ });
+
+ return ;
+};
+
+/* ------------------------------------------------------------------ */
+/* Trend by tool */
+/* ------------------------------------------------------------------ */
+
+/**
+ * One card per tool, in the leaderboard's order: its mean on this run, how it
+ * moved across the trend window, and the shape of that movement.
+ *
+ * EACH SPARKLINE IS ON ITS OWN SCALE -- the figure's description says so --
+ * so the cards compare a tool with itself, not with its neighbours. The pill
+ * is `windowChange`: first run to last run the tool has in the window, and
+ * absent with fewer than two. Lower is faster, so a fall is the good colour.
+ */
+export const TrendByTool = ({
+ section,
+ means,
+ trend,
+ hidden,
+}: {
+ section: SectionId;
+ means: readonly ToolMean[];
+ trend: readonly TrendPoint[];
+ hidden: readonly string[];
+}) => (
+ /* Registries have long names and six of them: three across, so the names fit. */
+
+ {means
+ .filter((m) => !hidden.includes(m.tool))
+ .map((m) => {
+ const series = trend.map((p) => {
+ const v = p.byTool[m.tool];
+ return typeof v === "number" ? v : null;
+ });
+ const moved = windowChange(series);
+ const pct = moved === null ? null : moved.change * 100;
+ return (
+
;
+
+ const columns: Column[] = [
+ toolColumn,
+ ...fixtures.map((f): Column => ({
+ id: f,
+ label: ,
+ name: fixtureLabel(f),
+ align: "right",
+ })),
+ { id: "total", label: "Total", name: "total", align: "right" },
+ ];
+
+ const rows: Row[] = countRows
+ .filter((r) => !hidden.includes(r.tool))
+ .map((r) => ({
+ id: r.tool,
+ cells: {
+ tool: { sort: toolLabel(r.tool, section), node: },
+ ...Object.fromEntries(
+ fixtures.map((f) => {
+ const count = r.byFixture[f];
+ const range = r.ranges[f];
+ return [
+ f,
+ {
+ sort: count ?? null,
+ node:
+ count === undefined ? (
+
+ ) : (
+
+ {count.toLocaleString("en-US")}
+ {range === undefined ? null : (
+
+ {range[0].toLocaleString("en-US")}–{range[1].toLocaleString("en-US")}
+
+ )}
+
+ ),
+ },
+ ];
+ }),
+ ),
+ total: {
+ sort: r.total,
+ node: {r.total.toLocaleString("en-US")},
+ },
+ },
+ }));
+
+ return ;
+};
diff --git a/apps/www/src/components/segmented.tsx b/apps/www/src/components/segmented.tsx
new file mode 100644
index 0000000000..af390532e5
--- /dev/null
+++ b/apps/www/src/components/segmented.tsx
@@ -0,0 +1,87 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+/**
+ * The one way this site draws "pick one of these": a row of chips, the chosen
+ * one filled with ink.
+ *
+ * Scenario, fixture, focused tool, run window and scale
+ * each had their own drawing of the same idea. The header is the deliberate
+ * exception -- it is the docs site's chrome and marks where you ARE with the
+ * docs' `bg-accent`; these mark what you PICKED.
+ *
+ * Two forms because the choice lives in two places. `Segmented` is for state
+ * a client island owns. `SegmentedLinks` is for a choice the server acts on:
+ * each chip is a real link, so the selection is in the URL, survives a reload
+ * and can be shared.
+ */
+const chip = (selected: boolean): string =>
+ `inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[0.8125rem] whitespace-nowrap transition-colors ${
+ selected
+ ? "bg-foreground text-background"
+ : "text-muted-foreground hover:bg-accent hover:text-foreground"
+ }`;
+
+const Row = ({ label, children }: { label?: string; children: ReactNode }) => (
+
+);
+
+export const Segmented = ({
+ label,
+ options,
+ value,
+ onChange,
+}: {
+ label?: string;
+ options: readonly { readonly value: string; readonly label: string }[];
+ value: string;
+ onChange: (value: string) => void;
+}) => (
+
+ {options.map((option) => (
+
+ ))}
+
+);
+
+export const SegmentedLinks = ({
+ label,
+ options,
+}: {
+ /** Omitted when a heading above the row already names the choice. */
+ label?: string;
+ options: readonly {
+ readonly href: string;
+ readonly label: ReactNode;
+ readonly selected: boolean;
+ readonly key: string;
+ }[];
+}) => (
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+);
diff --git a/apps/www/src/components/selection-chips.tsx b/apps/www/src/components/selection-chips.tsx
new file mode 100644
index 0000000000..1ea5ff0d89
--- /dev/null
+++ b/apps/www/src/components/selection-chips.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import { XIcon } from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { useQueryStates } from "nuqs";
+import { useLayoutEffect, useRef, type Ref } from "react";
+import { METRIC_LABELS } from "@/components/charts-echarts";
+import { FADE, SPRING } from "@/components/page-row";
+import { usePending } from "@/components/report-pending";
+import type { Metric } from "@/lib/figures";
+import { reportParams } from "@/lib/params";
+import { parseValueRange } from "@/lib/value-range";
+
+type Key = keyof typeof reportParams;
+
+/** A server-applied choice, as the server resolved it: only what actually narrowed the page. */
+export interface ServerChip {
+ readonly label: string;
+ readonly value: string;
+ /** The parameters that clearing it removes. */
+ readonly clear: readonly Key[];
+}
+
+const shortDay = (iso: string): string =>
+ new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+
+/* In: a fade and a small scale. Out: the same, popped out of the row so its neighbours close the gap. */
+const MOTION = {
+ initial: { opacity: 0, scale: 0.9 },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0.9 },
+ transition: SPRING,
+};
+/* Reduced: a fade in, and out at once. At rest it is MOTION's, so the server's render (which cannot know) hydrates. */
+const REDUCED = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, transition: { duration: 0 } },
+ transition: FADE,
+};
+
+/* Takes the ref AnimatePresence's popLayout hands its direct children, to lift a leaving chip out of the row. */
+const Chip = ({
+ label,
+ value,
+ onClear,
+ reduced,
+ ref,
+}: {
+ label: string;
+ value: string;
+ onClear: () => void;
+ reduced: boolean;
+ ref?: Ref;
+}) => (
+
+
+
+);
+
+/**
+ * The page's non-default choices, each one click from undone. Quiet by
+ * design: with everything at its default, nothing renders.
+ *
+ * Fixture and hidden tools have none: the menus in the row above already
+ * show and undo them. The other server choices (window, metric, exclusions)
+ * arrive resolved, so a value the page ignored has no chip; clearing one is a
+ * navigation. The trend's own choices (the y zoom, the pinned run) are read
+ * here, live, and cleared in place.
+ *
+ * A server chip is drawn only while the live URL still holds one of its
+ * parameters, so a cleared chip goes at once rather than when the server
+ * answers -- and if that navigation never lands, the URL decides.
+ *
+ * The list stays mounted, hidden while empty, so the last chip can animate
+ * out too. A leaving chip is popped out of the row, so the row's height is
+ * already what it will be, and the space around it (PageRow) moves with it.
+ */
+export const SelectionChips = ({
+ server,
+ metric,
+ runs,
+}: {
+ server: readonly ServerChip[];
+ /** The metric the trend plots, when it is not the default and can be plotted here. */
+ metric: Metric | null;
+ /** The runs the trend has loaded: a pin on any other date pins nothing. */
+ runs: readonly string[];
+}) => {
+ const [live, set] = useQueryStates(reportParams);
+ const { y, at, scale } = live;
+ const { startTransition, startToolsTransition } = usePending();
+ const reduced = useReducedMotion() === true;
+ const zoom = parseValueRange(y, scale === "log");
+ /* Clearing the focus leaves the chart's data as it is, so it reloads as the
+ tools menu does: the trend stays live. Everything else reloads the data. */
+ const navigate = (keys: readonly Key[]) =>
+ void set(Object.fromEntries(keys.map((k) => [k, null])), {
+ shallow: false,
+ startTransition: keys.every((k) => k === "focus") ? startToolsTransition : startTransition,
+ });
+ const held = (key: Key): boolean => {
+ const value: unknown = live[key];
+ const fallback: unknown =
+ "defaultValue" in reportParams[key] ? reportParams[key].defaultValue : null;
+ return Array.isArray(value) ? value.length > 0 : value !== null && value !== fallback;
+ };
+ const shown = server.filter((chip) => chip.clear.some(held));
+ const metricChip = held("metric") ? metric : null;
+
+ const pinned = at !== null && runs.includes(at);
+ const count =
+ shown.length + (metricChip === null ? 0 : 1) + (zoom === null ? 0 : 1) + (pinned ? 1 : 0);
+
+ const list = useRef(null);
+ // The last chip to leave is popped out of the row, which would snap the floating card shut around it: the row keeps its width until a chip returns.
+ useLayoutEffect(() => {
+ const ul = list.current;
+ if (ul === null) return;
+ const edges = [...ul.querySelectorAll("li")].map((li) => li.offsetLeft + li.offsetWidth);
+ ul.style.minWidth = count > 0 ? "" : `${Math.max(0, ...edges)}px`;
+ }, [count]);
+
+ return (
+
+ );
+};
diff --git a/apps/www/src/components/site-footer.tsx b/apps/www/src/components/site-footer.tsx
new file mode 100644
index 0000000000..9c61811f57
--- /dev/null
+++ b/apps/www/src/components/site-footer.tsx
@@ -0,0 +1,87 @@
+import Link from "next/link";
+import { cacheLife } from "next/cache";
+import { BotIcon } from "lucide-react";
+
+const LINK =
+ "inline-flex items-center gap-1 rounded-sm transition-colors hover:text-foreground focus-visible:outline-2";
+
+/**
+ * The site footer.
+ *
+ * THE FOOTER (`SiteFooter`) is the root layout's, once in the document: full
+ * width below the sidebar and the content, under a plain top border, holding
+ * the copyright and the way to /methodology. The Method, Environment,
+ * resources and trademark notice it used to carry are that page's
+ * (app/methodology/page.tsx), from the constants in lib/metadata.ts.
+ *
+ * DATA AND SOURCE (`DataAndSource`, components/raw-results.tsx) is the page's,
+ * ending its content column: the raw result files it fetched.
+ *
+ * Citing is the sidebar's (`SidebarCite`, in components/cite.tsx).
+ *
+ * A RUN IS CITED BY ITS DATE. The published tree records no run id and no
+ * commit sha, so neither is shown or cited; the citation's URL is the page
+ * pinned to its `date`. The cadence is nightly.
+ *
+ * THE WORDING IS FACT-CHECKED; restyle it freely, reword it carefully. It was
+ * ported from the previous app's footer (tmp/app/src/components/footer.tsx,
+ * `app/` on origin/main) with two corrections, and these are the reasons the
+ * wording is what it is:
+ *
+ * 1. The original said the suite counts packages "& use that to determine the
+ * average speed relative to the number of packages installed", which
+ * describes a site that ranks on a normalised figure. This one does not.
+ * docs/METHODOLOGY.md is explicit that both numbers are published and
+ * neither alone is the answer. The copy says that.
+ *
+ * 2. The original listed the processor as "4 cores (Ampere Altra Arm)". The
+ * model name has no source in this repository, so it is gone. The workflow
+ * label is read off .github/workflows/benchmark.yaml, which runs every
+ * benchmark job on ubuntu-24.04-arm; Ubuntu 24.04 and arm64 follow from it.
+ * The remaining figures are GitHub's published specification for standard
+ * public runners, attributed as such rather than stated as measured here.
+ *
+ * Note for whoever touches the root README: it still says the workflow
+ * label is `ubuntu-latest`, which has not been true since the suite moved
+ * to arm runners.
+ *
+ * No commit sha is shown: nothing in the published tree records one.
+ *
+ * The colour-scheme switcher is not here: it is in the sidebar's footer, as
+ * in the vlt docs, and only there.
+ */
+/* The year, read once a day rather than on every render, so the footer prerenders. */
+const Year = async () => {
+ "use cache";
+ cacheLife("days");
+ return new Date().getFullYear();
+};
+
+/** The site footer, full width below the sidebar and the content: nothing in it depends on the page. */
+export const SiteFooter = () => (
+
+);
diff --git a/apps/www/src/components/snapshot-banner.tsx b/apps/www/src/components/snapshot-banner.tsx
new file mode 100644
index 0000000000..dfba5ad0e9
--- /dev/null
+++ b/apps/www/src/components/snapshot-banner.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { ArrowRight, History } from "lucide-react";
+import Link from "next/link";
+import { WithLocation } from "@/components/with-location";
+import { longDate } from "@/lib/findings";
+import { isSectionId } from "@/lib/sections";
+
+/**
+ * On a benchmark pinned to a past run (`?date=`), a strip in the header,
+ * always in view: this is a snapshot, not the latest, and the way back to the
+ * latest -- the same view, every other choice kept. Its presence sets
+ * `--banner-height` (globals.css), which moves everything under the header.
+ */
+const Banner = ({
+ pathname,
+ query,
+ latest,
+}: {
+ pathname: string | null;
+ query: string;
+ latest: string | null;
+}) => {
+ const params = new URLSearchParams(query);
+ const date = params.get("date");
+ if (
+ latest === null ||
+ pathname === null ||
+ date === null ||
+ !/^\d{4}-\d{2}-\d{2}$/.test(date) ||
+ date >= latest ||
+ !isSectionId(pathname.split("/")[1])
+ )
+ return null;
+ params.delete("date");
+ const rest = params.toString();
+ return (
+
+
+
+ A snapshot report from {longDate(date)}
+ , not the latest run.{" "}
+
+ See the latest
+
+
+
+
+ );
+};
+
+export const SnapshotBanner = ({ latest }: { latest: string | null }) => (
+
+ {({ pathname, query }) => }
+
+);
diff --git a/apps/www/src/components/tabbed-figure.tsx b/apps/www/src/components/tabbed-figure.tsx
new file mode 100644
index 0000000000..f2b30abbb3
--- /dev/null
+++ b/apps/www/src/components/tabbed-figure.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import { Figure } from "@/components/report";
+
+/**
+ * Tabs in a figure's top row: the docs' tabbed code card
+ * (vltpkg/www/docs/src/components/code-card.tsx), ported. The active tab's
+ * face flows into the panel, with a concave corner either side of it; the
+ * first tab's left side runs straight down the panel's edge.
+ */
+const Corner = ({ side }: { side: "left" | "right" }) => (
+
+
+
+);
+
+export const FigureTabStrip = ({
+ label,
+ tabs,
+ value,
+ onChange,
+}: {
+ label: string;
+ tabs: readonly { readonly value: string; readonly label: ReactNode }[];
+ value: string;
+ onChange: (value: string) => void;
+}) => (
+
+ );
+};
diff --git a/apps/www/src/components/theme-provider.tsx b/apps/www/src/components/theme-provider.tsx
new file mode 100644
index 0000000000..4d93b66ff3
--- /dev/null
+++ b/apps/www/src/components/theme-provider.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { ThemeProvider as NextThemes } from "next-themes";
+import type { ReactNode } from "react";
+
+/**
+ * Theme provider.
+ *
+ * `attribute="data-theme"` is NOT optional and NOT a default. The ported
+ * vltpkg docs palette switches on
+ * `@custom-variant dark (&:where([data-theme="dark"], …))`, so next-themes'
+ * default — toggling a `class` — would leave every dark rule present in the
+ * stylesheet and never applied. Dark mode would be silently inert: no error, no
+ * failed build, and a light-mode screenshot that looks entirely correct.
+ *
+ * That is the same failure as the tool ramp Tailwind tree-shook away, one layer
+ * up, which is why it is asserted by a named inventory item rather than trusted
+ * as configuration.
+ */
+export const ThemeProvider = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
diff --git a/apps/www/src/components/theme-switcher.tsx b/apps/www/src/components/theme-switcher.tsx
new file mode 100644
index 0000000000..3ff40caee8
--- /dev/null
+++ b/apps/www/src/components/theme-switcher.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+/*
+ * The vlt docs' theme switcher (vltpkg/www/docs/src/components/theme-switcher.tsx),
+ * ported as it stands; it sits in the sidebar's footer, as there. Changed: `cn`
+ * from `@/lib/utils`, and the fallback is named so strict indexing types it.
+ */
+import { useSyncExternalStore } from "react";
+import { useTheme } from "next-themes";
+import { DropdownMenu } from "radix-ui";
+import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
+
+const system = { value: "system", label: "System", Icon: MonitorIcon };
+const themes = [
+ system,
+ { value: "dark", label: "Dark", Icon: MoonIcon },
+ { value: "light", label: "Light", Icon: SunIcon },
+];
+
+// the theme is only known on the client, so render the neutral icon until hydrated
+const useMounted = () =>
+ useSyncExternalStore(
+ () => () => {},
+ () => true,
+ () => false,
+ );
+
+export const ThemeSwitcher = () => {
+ const { theme = "system", setTheme } = useTheme();
+ const mounted = useMounted();
+ const current = themes.find((t) => t.value === theme) ?? system;
+ const Icon = mounted ? current.Icon : MonitorIcon;
+
+ return (
+
+
+
+
+
+
+
+ {themes.map(({ value, label, Icon }) => (
+
+
+ {label}
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/apps/www/src/components/toc-minimap.tsx b/apps/www/src/components/toc-minimap.tsx
new file mode 100644
index 0000000000..bab4b68606
--- /dev/null
+++ b/apps/www/src/components/toc-minimap.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { goToAnchor } from "@/components/heading-anchor";
+/*
+ * TOC Minimap by ncdai (chanhdai.com/components/toc-minimap), taken from its
+ * registry item (https://chanhdai.com/r/toc-minimap.json) and adapted:
+ *
+ * - Radix's hover card rather than Base UI's, since Radix is what this app
+ * has: `asChild`, `openDelay`/`closeDelay`, and `data-[state=open]` where
+ * the original reads `data-popup-open`.
+ * - No sound on open. The original plays one through a soundcn dependency.
+ * - `data-active` is present or absent, not "true"/"false", so the
+ * `data-active:` variants match only the active items.
+ * - Fixed to the viewport's right edge and vertically centred, in the page
+ * margin, so it takes no column; shown from 90rem up, where the 80rem page
+ * column leaves room for it. Ticks are flush right.
+ */
+import { HoverCard } from "radix-ui";
+import { useEffect, useMemo, useState } from "react";
+import { cn } from "@/lib/utils";
+
+export interface TOCItemType {
+ title: React.ReactNode;
+ url: string;
+ depth: number;
+}
+
+export const TOCMinimap = ({ items, className }: { items: TOCItemType[]; className?: string }) => {
+ const itemIds = useMemo(() => items.map((item) => item.url.replace("#", "")), [items]);
+ const active = useActiveHeadings(itemIds);
+
+ if (!items.length) return null;
+
+ return (
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/*
+ * Exactly one item: the one being read. A reading line sits a quarter of the
+ * way down the space under the sticky header; the active item is the one whose
+ * element most recently crossed it (the greatest top still above the line, the
+ * deeper item on a tie -- a figure over the section that holds it). Before the
+ * first crosses, the first is active; at the very bottom of the page, the last
+ * one on screen is, since the final sections may be too short to ever reach
+ * the line. Measured on scroll and resize, once per frame.
+ */
+const useActiveHeadings = (itemIds: string[]) => {
+ const [active, setActive] = useState>(new Set());
+
+ useEffect(() => {
+ let frame = 0;
+ const measure = () => {
+ frame = 0;
+ /* The header as drawn: the navbar, and the snapshot banner when there is one. */
+ const header = document.querySelector("header")?.getBoundingClientRect().height ?? 56;
+ const line = header + (window.innerHeight - header) / 4;
+ const atBottom =
+ window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
+ let best: { id: string; top: number } | null = null;
+ for (const id of itemIds) {
+ const element = document.getElementById(id);
+ if (element === null) continue;
+ const top = element.getBoundingClientRect().top;
+ const reached = atBottom ? top < window.innerHeight : top <= line;
+ if (reached && (best === null || top >= best.top)) best = { id, top };
+ }
+ const id = best?.id ?? itemIds.find((i) => document.getElementById(i) !== null);
+ setActive((current) =>
+ id === undefined
+ ? new Set()
+ : current.size === 1 && current.has(id)
+ ? current
+ : new Set([id]),
+ );
+ };
+ const schedule = () => {
+ if (frame === 0) frame = requestAnimationFrame(measure);
+ };
+ measure();
+ window.addEventListener("scroll", schedule, { passive: true });
+ window.addEventListener("resize", schedule);
+ window.addEventListener("hashchange", schedule);
+ return () => {
+ cancelAnimationFrame(frame);
+ window.removeEventListener("scroll", schedule);
+ window.removeEventListener("resize", schedule);
+ window.removeEventListener("hashchange", schedule);
+ };
+ }, [itemIds]);
+
+ return active;
+};
+
+const handleItemClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ goToAnchor((e.currentTarget.getAttribute("href") ?? "").replace("#", ""));
+};
diff --git a/apps/www/src/components/tool-name.tsx b/apps/www/src/components/tool-name.tsx
new file mode 100644
index 0000000000..ada567f4be
--- /dev/null
+++ b/apps/www/src/components/tool-name.tsx
@@ -0,0 +1,91 @@
+import type { ComponentType, SVGProps } from "react";
+import { Astro } from "@/components/icons/astro";
+import { Aube } from "@/components/icons/aube";
+import { Aws } from "@/components/icons/aws";
+import { Berry } from "@/components/icons/berry";
+import { Bun } from "@/components/icons/bun";
+import { Cloudsmith } from "@/components/icons/cloudsmith";
+import { Deno } from "@/components/icons/deno";
+import { Github } from "@/components/icons/github";
+import { Jfrog } from "@/components/icons/jfrog";
+import { Next } from "@/components/icons/next";
+import { Node } from "@/components/icons/node";
+import { Npm } from "@/components/icons/npm";
+import { Nx } from "@/components/icons/nx";
+import { Package } from "@/components/icons/package";
+import { Pnpm } from "@/components/icons/pnpm";
+import { Svelte } from "@/components/icons/svelte";
+import { Turbo } from "@/components/icons/turbo";
+import { Upm } from "@/components/icons/upm";
+import { Vlt } from "@/components/icons/vlt";
+import { Vp } from "@/components/icons/vp";
+import { Vue } from "@/components/icons/vue";
+import { Yarn } from "@/components/icons/yarn";
+import { Zpm } from "@/components/icons/zpm";
+import type { SectionId } from "@/lib/sections";
+import { fixtureLabel, toolLabel } from "@/lib/tools";
+
+type Icon = ComponentType>;
+
+/*
+ * The previous app's icon assignments (tmp/app/src/lib/get-icons.ts), kept
+ * as they were -- including pacquet under the pnpm mark, since it is pnpm's
+ * own rewrite. A name with no entry gets no icon and its label, which is the
+ * part that carries identity anyway.
+ */
+export const TOOL_ICONS: { readonly [tool: string]: Icon } = {
+ aube: Aube,
+ aws: Aws,
+ berry: Berry,
+ bun: Bun,
+ cloudsmith: Cloudsmith,
+ deno: Deno,
+ github: Github,
+ jfrog: Jfrog,
+ node: Node,
+ npm: Npm,
+ nx: Nx,
+ pacquet: Pnpm,
+ pnpm: Pnpm,
+ turbo: Turbo,
+ upm: Upm,
+ vlt: Vlt,
+ vp: Vp,
+ yarn: Yarn,
+ zpm: Zpm,
+};
+
+const FIXTURE_ICONS: { readonly [fixture: string]: Icon } = {
+ astro: Astro,
+ babylon: Package,
+ large: Package,
+ next: Next,
+ svelte: Svelte,
+ vue: Vue,
+};
+
+const Mark = ({ icon: IconFor }: { icon: Icon | undefined }) =>
+ IconFor === undefined ? null : ;
+
+/** A tool as it is named everywhere outside a chart: its mark, then its name. */
+export const ToolName = ({
+ tool,
+ section,
+ className = "",
+}: {
+ tool: string;
+ section: SectionId;
+ className?: string;
+}) => (
+
+
+ {toolLabel(tool, section)}
+
+);
+
+export const FixtureName = ({ fixture }: { fixture: string }) => (
+
+
+ {fixtureLabel(fixture)}
+
+);
diff --git a/apps/www/src/components/tools-menu.tsx b/apps/www/src/components/tools-menu.tsx
new file mode 100644
index 0000000000..09a05919e3
--- /dev/null
+++ b/apps/www/src/components/tools-menu.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { Boxes, ChevronDown, Server, SquareTerminal } from "lucide-react";
+import { useQueryState } from "nuqs";
+import { usePending } from "@/components/report-pending";
+import { ToolName } from "@/components/tool-name";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { toolColor } from "@/lib/chart-model";
+import { reportParams } from "@/lib/params";
+import type { SectionId } from "@/lib/sections";
+import { cn } from "@/lib/utils";
+
+/** What the tools are called on each benchmark: the same control lists registries there. */
+const NOUN: { readonly [S in SectionId]: string } = {
+ "package-managers": "package managers",
+ registries: "registries",
+ "task-runners": "task runners",
+};
+
+/** Each benchmark's mark for its tools, leading the trigger so it is told
+ * apart from the fixture menu beside it at a glance. */
+const ICON: { readonly [S in SectionId]: typeof Boxes } = {
+ "package-managers": Boxes,
+ registries: Server,
+ "task-runners": SquareTerminal,
+};
+
+/**
+ * Which tools the page shows: the multi-select under the page's title, where the previous
+ * app had its package-manager filter (tmp/app/src/components/
+ * package-manager-filter.tsx) -- check items, select all, clear.
+ *
+ * ONE PARAMETER, `hide`, and it is the same one the trend legend's entries
+ * toggle, so the menu and the legend cannot disagree. It lists what is hidden
+ * rather than what is shown so that the default -- everything -- is an empty
+ * list and stays out of the URL, and so that a tool published tomorrow is
+ * shown without anyone's saved link having to name it.
+ *
+ * It is a SERVER parameter: hidden tools leave the tables and the bars, which
+ * the server shapes. So the write is not shallow.
+ *
+ * A name in the URL that this scenario's files do not contain hides nothing.
+ */
+export const useHidden = (tools: readonly string[]) => {
+ const { startToolsTransition } = usePending();
+ const [hide, setHide] = useQueryState("hide", {
+ ...reportParams.hide,
+ shallow: false,
+ startTransition: startToolsTransition,
+ });
+ const hidden = hide.filter((tool) => tools.includes(tool));
+ return {
+ hidden,
+ toggle: (tool: string) =>
+ void setHide(hidden.includes(tool) ? hidden.filter((t) => t !== tool) : [...hidden, tool]),
+ showAll: () => void setHide([]),
+ hideAllBut: (keep: readonly string[]) => void setHide(tools.filter((t) => !keep.includes(t))),
+ };
+};
+
+/** The checkable entries. */
+const ToolsMenuItems = ({ tools, section }: { tools: readonly string[]; section: SectionId }) => {
+ const { hidden, toggle, showAll, hideAllBut } = useHidden(tools);
+ return (
+ <>
+
+ {NOUN[section]}
+
+ {tools.map((tool) => (
+ toggle(tool)}
+ /* Stay open: this is a multi-select, and closing on every tick
+ would make choosing three tools three trips. */
+ onSelect={(event) => event.preventDefault()}
+ >
+
+
+
+ ))}
+
+ {
+ event.preventDefault();
+ showAll();
+ }}
+ >
+ Select all
+
+ {
+ event.preventDefault();
+ hideAllBut([]);
+ }}
+ >
+ Clear
+
+ >
+ );
+};
+
+export const ToolsMenu = ({
+ tools,
+ section,
+ className,
+}: {
+ /** Every tool this scenario's files contain, in ranking order. */
+ tools: readonly string[];
+ section: SectionId;
+ className?: string;
+}) => {
+ const { hidden } = useHidden(tools);
+ const shown = tools.length - hidden.length;
+ const Icon = ICON[section];
+ return (
+
+
+ {/* A fixed width from the caller, so ticking a tool does not
+ resize the button and shove its neighbour along the row. */}
+
+
+
+
+
+
+ );
+};
+
+/** Undoes every hidden tool, as the menu's "Select all" does: the lines come back at once. */
+export const ShowAllTools = ({ className }: { className?: string }) => {
+ const { showAll } = useHidden([]);
+ return (
+
+ );
+};
diff --git a/apps/www/src/components/ui/breadcrumb.tsx b/apps/www/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000000..e380339e75
--- /dev/null
+++ b/apps/www/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,103 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
+import { Slot } from "radix-ui";
+
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ );
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
+ return (
+