From f7518979096cae7f4e3358a732263dbb257acca2 Mon Sep 17 00:00:00 2001 From: Jason Korol Date: Thu, 22 Oct 2026 15:57:56 -0400 Subject: [PATCH 1/9] feat: write per-date summaries in build-manifest One file per run with every benchmark result from that date, exit codes included, so the site can read a whole run in one request instead of ~54. Older dates get backfilled on the next deploy since build-manifest scans the full history. --- apps/data/bin/build-manifest.mts | 30 +++++++++++++++--- apps/data/src/client.ts | 7 +++++ apps/data/src/index.ts | 3 ++ apps/data/src/manifest.ts | 2 ++ apps/data/src/paths.ts | 5 +++ apps/data/src/scan.ts | 2 +- apps/data/src/summary.ts | 53 ++++++++++++++++++++++++++++++++ apps/data/test/summary.test.ts | 33 ++++++++++++++++++++ 8 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 apps/data/src/summary.ts create mode 100644 apps/data/test/summary.test.ts diff --git a/apps/data/bin/build-manifest.mts b/apps/data/bin/build-manifest.mts index 8c2ee19844..971e6bddc1 100755 --- a/apps/data/bin/build-manifest.mts +++ b/apps/data/bin/build-manifest.mts @@ -15,15 +15,18 @@ * * 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 + * Writes only index.json, manifest.json, the manifest/ shards and the summary/ + * files. 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 { mkdirSync, readFileSync, 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"; +import { buildSummary } from "../src/summary.ts"; +import { INDEX_FILE, MANIFEST_DIR, MANIFEST_FILE, SUMMARY_DIR } from "../src/paths.ts"; +import { isDateKey } from "@benchmarks/types"; const argv = process.argv.slice(2); let out: string | undefined; @@ -107,9 +110,28 @@ for (const [name, entry] of Object.entries(manifest.entries)) { shardCount++; } +// One results summary per date (src/summary.ts), deterministic for the same +// reason as the shards. latest/ is merged rather than a run, so it gets none. +const summaryDir = join(out, SUMMARY_DIR); +mkdirSync(summaryDir, { recursive: true }); +let summaryCount = 0; +const skipped: string[] = []; +for (const listing of listings) { + if (!isDateKey(listing.name) || listing.dir === undefined) continue; + const dir = listing.dir; + const built = buildSummary(listing, (file) => readFileSync(join(dir, file), "utf8")); + skipped.push(...built.skipped.map((file) => `${listing.name}/${file}`)); + writeFileSync(join(summaryDir, `${listing.name}.json`), `${JSON.stringify(built.summary)}\n`); + summaryCount++; +} +if (skipped.length > 0) { + console.warn(`left out of the summaries, not benchmark files: ${skipped.join(", ")}`); +} + const dirCount = Object.keys(manifest.entries).length; console.log( - `wrote ${INDEX_FILE}, ${MANIFEST_FILE} and ${shardCount} ${MANIFEST_DIR}/ shards to ${out}: ` + + `wrote ${INDEX_FILE}, ${MANIFEST_FILE}, ${shardCount} ${MANIFEST_DIR}/ shards and ` + + `${summaryCount} ${SUMMARY_DIR}/ files 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/src/client.ts b/apps/data/src/client.ts index 1e66629464..3aabc9a515 100644 --- a/apps/data/src/client.ts +++ b/apps/data/src/client.ts @@ -25,8 +25,10 @@ import { manifestShardPath, packageCountFile, processCountFile, + summaryPath, VERSIONS_FILE, } from "./paths.ts"; +import type { DateSummary } from "./summary.ts"; export type FetchLike = (url: string) => Promise<{ ok: boolean; @@ -129,6 +131,11 @@ export class DataClient { return this.#get(date, processCountFile(fixture, variation)); } + /** Every benchmark file of one date in one fetch (src/summary.ts); 404 until a deploy has written it. */ + getSummary(date: string): Promise { + return this.#get(...summaryPath(date)); + } + getChartData(date: string): Promise { return this.#get(date, CHART_DATA_FILE); } diff --git a/apps/data/src/index.ts b/apps/data/src/index.ts index e0da4af58d..0f8813f979 100644 --- a/apps/data/src/index.ts +++ b/apps/data/src/index.ts @@ -21,8 +21,11 @@ export { packageCountFile, parseDataFile, processCountFile, + SUMMARY_DIR, + summaryPath, VERSIONS_FILE, type FileKind, type ParsedDataFile, } from "./paths.ts"; export { scanRoots } from "./scan.ts"; +export { buildSummary, type DateSummary, type SummaryResult } from "./summary.ts"; diff --git a/apps/data/src/manifest.ts b/apps/data/src/manifest.ts index 83f33451e1..9ef466dbfd 100644 --- a/apps/data/src/manifest.ts +++ b/apps/data/src/manifest.ts @@ -66,6 +66,8 @@ export interface DataManifest { export interface DirectoryListing { name: string; files: string[]; + /** Where the directory is on disk, when it was scanned from one. */ + dir?: string; } const addPair = (into: VariationsByFixture, fixture: string, variation: string): void => { diff --git a/apps/data/src/paths.ts b/apps/data/src/paths.ts index 8a04263253..1852cca603 100644 --- a/apps/data/src/paths.ts +++ b/apps/data/src/paths.ts @@ -34,6 +34,11 @@ export const MANIFEST_DIR = "manifest"; */ export const manifestShardPath = (name: string): string[] => [MANIFEST_DIR, `${name}.json`]; +/** Directory holding one results summary per date (src/summary.ts), beside the shards. */ +export const SUMMARY_DIR = "summary"; + +export const summaryPath = (date: string): string[] => [SUMMARY_DIR, `${date}.json`]; + const PACKAGE_COUNT_SUFFIX = "-package-count.json"; const PROCESS_COUNT_SUFFIX = "-process-count.json"; const JSON_SUFFIX = ".json"; diff --git a/apps/data/src/scan.ts b/apps/data/src/scan.ts index 24af88cd57..5c9b96bfc4 100644 --- a/apps/data/src/scan.ts +++ b/apps/data/src/scan.ts @@ -31,7 +31,7 @@ export const scanRoots = (roots: string[]): DirectoryListing[] => { } catch { continue; } - byName.set(name, { name, files }); + byName.set(name, { name, files, dir }); } } diff --git a/apps/data/src/summary.ts b/apps/data/src/summary.ts new file mode 100644 index 0000000000..ab09e68c96 --- /dev/null +++ b/apps/data/src/summary.ts @@ -0,0 +1,53 @@ +/** + * One date's benchmark results, in one file. + * + * A trend over many scenarios -- the "average" view averages every scenario + * on every fixture -- otherwise costs one fetch per benchmark file per run: + * 54 a run for package managers, about 6,500 for 120 days. This is the same + * numbers, lifted verbatim out of those files, so it costs one. + * + * Not `chart-data.json`. That file gives a failed run a time, and nothing in + * it says the run failed. Every result here keeps its `exit_codes`, so a + * consumer applies the same rule to it as to the file it came from. + */ +import type { BenchmarkResult } from "@benchmarks/types"; +import type { DirectoryListing } from "./manifest.ts"; +import { parseDataFile } from "./paths.ts"; + +/** What a trend needs of a result, exactly as the benchmark file has it. */ +export type SummaryResult = Pick; + +/** Variation, then fixture, to that file's results. */ +export type DateSummary = { + [variation: string]: { [fixture: string]: SummaryResult[] | undefined } | undefined; +}; + +/** + * Summarise one date directory. `read` returns a file's text; a file that + * does not parse as a benchmark file is reported in `skipped`, not guessed at. + */ +export const buildSummary = ( + listing: DirectoryListing, + read: (file: string) => string, +): { summary: DateSummary; skipped: string[] } => { + const summary: DateSummary = {}; + const skipped: string[] = []; + for (const file of listing.files) { + const parsed = parseDataFile(file); + if (parsed?.kind !== "benchmark") continue; + let results: unknown; + try { + results = (JSON.parse(read(file)) as { results?: unknown }).results; + } catch { + results = undefined; + } + if (!Array.isArray(results)) { + skipped.push(file); + continue; + } + (summary[parsed.variation] ??= {})[parsed.fixture] = (results as BenchmarkResult[]).map( + ({ command, mean, stddev, exit_codes }) => ({ command, mean, stddev, exit_codes }), + ); + } + return { summary, skipped }; +}; diff --git a/apps/data/test/summary.test.ts b/apps/data/test/summary.test.ts new file mode 100644 index 0000000000..1bcb053a67 --- /dev/null +++ b/apps/data/test/summary.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildSummary } from "../src/summary.ts"; + +describe("buildSummary", () => { + it("lifts each benchmark file's results verbatim, exit codes and all", () => { + const files: { [name: string]: string } = { + "next-clean.json": JSON.stringify({ + results: [ + { command: "npm", mean: 2, stddev: 0.1, median: 2, times: [2], exit_codes: [0] }, + { command: "zpm", mean: 0, stddev: 0, median: 0, times: [0], exit_codes: [1] }, + ], + }), + "next-registry-clean.json": JSON.stringify({ results: [] }), + "next-clean-package-count.json": "{}", + "chart-data.json": "{}", + "astro-clean.json": "not json", + }; + const { summary, skipped } = buildSummary( + { name: "2026-10-19", files: Object.keys(files) }, + (file) => files[file] ?? "", + ); + expect(summary).toEqual({ + clean: { + next: [ + { command: "npm", mean: 2, stddev: 0.1, exit_codes: [0] }, + { command: "zpm", mean: 0, stddev: 0, exit_codes: [1] }, + ], + }, + "registry-clean": { next: [] }, + }); + expect(skipped).toEqual(["astro-clean.json"]); + }); +}); From 0f1edb1d97e302e4860d237f01aacd1d46bf3150 Mon Sep 17 00:00:00 2001 From: Jason Korol Date: Thu, 22 Oct 2026 15:57:57 -0400 Subject: [PATCH 2/9] fix: average trend ignored the date range The average view capped its trend at 400 files, which came out to the last 7 runs whatever range you picked. It now reads the per-date summaries and only falls back to raw files where a summary is missing. Also bumps the default range to 120 days. --- apps/www/src/lib/load.ts | 87 ++++++++++++++++++++++++++++----- apps/www/src/lib/params.ts | 2 +- apps/www/src/lib/report.ts | 19 +++++-- apps/www/test/average.test.ts | 13 +++++ apps/www/test/scenarios.test.ts | 4 +- 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/apps/www/src/lib/load.ts b/apps/www/src/lib/load.ts index b9534d37ec..aab48d2e29 100644 --- a/apps/www/src/lib/load.ts +++ b/apps/www/src/lib/load.ts @@ -1,13 +1,14 @@ /** * Server-side loading for one scenario. * - * Every fetch here is a measurement file. Nothing reads `chart-data.json`: - * it is derived, it has drifted into six shapes across the archive, and on - * 2026-04-14 it describes registry data that does not exist. The files are the - * measurement. + * Every fetch here is a measurement file, or a date's summary of them + * (apps/data src/summary.ts): the same results, verbatim, in one file. Nothing + * reads `chart-data.json`: it is derived, it has drifted into six shapes across + * the archive, on 2026-04-14 it describes registry data that does not exist, + * and it gives a failed run a time. The files are the measurement. */ import type { BenchmarkFile, Fixture, PackageCount, ProcessCount } from "@benchmarks/types"; -import { DataClient } from "@benchmarks/data"; +import { DataClient, type DateSummary } from "@benchmarks/data"; import { probe } from "./data"; import { applyExclusions } from "./figures"; import { averageFiles, usable } from "./measurements"; @@ -248,9 +249,35 @@ export const loadScenario = async ( */ export const AVERAGE_TREND_BUDGET = 400; -/** The most recent dates whose files fit the budget, and never fewer than two. */ -export const averageTrendDates = (dates: readonly string[], cellsPerDate: number): string[] => - dates.slice(-Math.max(2, Math.floor(AVERAGE_TREND_BUDGET / Math.max(cellsPerDate, 1)))); +/** + * The window's dates the average's trend draws: every date with a summary, + * which costs one fetch, and of the rest the most recent whose files fit the + * budget, never fewer than two. + */ +export const averageTrendDates = ( + dates: readonly string[], + cellsPerDate: number, + summarised: ReadonlySet = new Set(), +): string[] => { + const raw = dates + .filter((d) => !summarised.has(d)) + .slice(-Math.max(2, Math.floor(AVERAGE_TREND_BUDGET / Math.max(cellsPerDate, 1)))); + return dates.filter((d) => summarised.has(d) || raw.includes(d)); +}; + +/** Each date's summary, where a deploy has written one; a date without is left out. */ +export const loadSummaries = async ( + client: DataClient, + dates: readonly string[], +): Promise> => { + const found = await Promise.all(dates.map((d) => probe(() => client.getSummary(d)))); + return new Map( + dates.flatMap((d, i) => { + const summary = found[i]; + return summary?.state === "present" ? [[d, summary.value] as const] : []; + }), + ); +}; /** One measured scenario's file for one fixture. */ export interface RunCell { @@ -259,11 +286,38 @@ export interface RunCell { readonly file: BenchmarkFile; } +/* What a summary leaves out of a result; nothing on a trend reads it. */ +const unsummarised = { + median: Number.NaN, + min: Number.NaN, + max: Number.NaN, + user: Number.NaN, + system: Number.NaN, + times: [], +}; + const loadCells = async ( client: DataClient, date: string, plan: { readonly [fixture: string]: readonly string[] }, + summary?: DateSummary, ): Promise => { + if (summary !== undefined) { + return Object.entries(plan).flatMap(([fixture, variations]) => + variations.flatMap((variation) => { + const results = summary[variation]?.[fixture]; + return results === undefined + ? [] + : [ + { + fixture, + variation, + file: { results: results.map((r) => ({ ...r, ...unsummarised })) }, + }, + ]; + }), + ); + } const cells = await Promise.all( Object.entries(plan).flatMap(([fixture, variations]) => variations.map(async (variation) => { @@ -288,11 +342,17 @@ export const loadAverage = async ( date: string, plan: { readonly [fixture: string]: readonly string[] }, trendDates: readonly string[], - options: { readonly exclude?: readonly string[] } = {}, + options: { + readonly exclude?: readonly string[]; + /** From `loadSummaries`: a summarised run costs one fetch, not one per file. */ + readonly summaries?: ReadonlyMap; + } = {}, ): Promise => { - // The run shown is usually the trend's last date; fetched once either way. + // The run shown is usually the trend's last date; fetched once either way, + // and from its files, which the report reads in full. const dates = trendDates.includes(date) ? trendDates : [...trendDates, date]; - const loaded = await Promise.all(dates.map((d) => loadCells(client, d, plan))); + const summaryOf = (d: string) => (d === date ? undefined : options.summaries?.get(d)); + const loaded = await Promise.all(dates.map((d) => loadCells(client, d, plan, summaryOf(d)))); const trend = trendDates.flatMap( (d, i) => trendPoint(d, averageFiles(loaded[i] ?? [], plan)) ?? [], ); @@ -307,7 +367,10 @@ export const loadAverage = async ( })), trend: applyExclusions(trend, options.exclude ?? []), trendCost: { - requests: dates.length * Object.values(plan).flat().length, + requests: dates.reduce( + (sum, d) => sum + (summaryOf(d) === undefined ? Object.values(plan).flat().length : 1), + 0, + ), dates: trendDates.length, }, }; diff --git a/apps/www/src/lib/params.ts b/apps/www/src/lib/params.ts index 00c5a40147..b71cd34073 100644 --- a/apps/www/src/lib/params.ts +++ b/apps/www/src/lib/params.ts @@ -43,7 +43,7 @@ export const reportParams = { /** SERVER. One fixture; absent means all of them, averaged. */ fixture: parseAsString, /** SERVER. A preset trend window: this many days, ending at the run shown. */ - range: parseAsNumberLiteral(RANGES).withDefault(30), + range: parseAsNumberLiteral(RANGES).withDefault(120), /** SERVER. An exact trend window. Both must be published dates; see `trendWindow`. */ from: parseAsString, to: parseAsString, diff --git a/apps/www/src/lib/report.ts b/apps/www/src/lib/report.ts index e73a4fdfb7..b31b6c7c1b 100644 --- a/apps/www/src/lib/report.ts +++ b/apps/www/src/lib/report.ts @@ -18,7 +18,13 @@ import { dataClient, probe } from "./data"; import { flagOutliers, meanBars, type Bar } from "./figures"; import { formatDuration, formatRatio, type Finding } from "./findings"; import { scenarioFacts, scopedFinding } from "./headline"; -import { averageTrendDates, loadAverage, loadScenario, type TrendPoint } from "./load"; +import { + averageTrendDates, + loadAverage, + loadScenario, + loadSummaries, + type TrendPoint, +} from "./load"; import { countRows, perPackageByFixture, @@ -362,13 +368,18 @@ export const loadReport = async ({ * them -- the report's numbers are about it. */ const exclude = query.exclude.filter((e) => e.split("@").at(-1) !== date); - /* The derived view's trend is cut to what its request budget allows. */ + /* The derived view's trend reads each run's summary; a run without one is cut to what the request budget allows. */ const shownPlan = Object.fromEntries(fixtures.map((f) => [f, plan[f] ?? []])); const filesPerRun = Object.values(shownPlan).flat().length; + const summaries = averageOf === null ? undefined : await loadSummaries(client, window.dates); const trendDates = - averageOf === null ? window.dates : averageTrendDates(window.dates, filesPerRun); + summaries === undefined + ? window.dates + : averageTrendDates(window.dates, filesPerRun, new Set(summaries.keys())); const average = - averageOf === null ? null : await loadAverage(client, date, shownPlan, trendDates, { exclude }); + averageOf === null + ? null + : await loadAverage(client, date, shownPlan, trendDates, { exclude, summaries }); const data = average ?? (await loadScenario(client, date, selected.variation, fixtures, trendDates, { diff --git a/apps/www/test/average.test.ts b/apps/www/test/average.test.ts index ba040e7a9c..2a7898e692 100644 --- a/apps/www/test/average.test.ts +++ b/apps/www/test/average.test.ts @@ -240,3 +240,16 @@ describe("the average report's twins", () => { expect(text.indexOf("- [average]")).toBeLessThan(text.indexOf("- [clean]")); }); }); + +describe("averageTrendDates with summaries", () => { + it("keeps every summarised date and budgets only the rest", () => { + const dates = Array.from({ length: 30 }, (_, i) => `d${String(i).padStart(2, "0")}`); + const summarised = new Set(dates.slice(0, 20)); + // 54 files a run leaves room for 7 raw runs: the last 7 of the 10 unsummarised. + expect(averageTrendDates(dates, 54, summarised)).toEqual([ + ...dates.slice(0, 20), + ...dates.slice(23), + ]); + expect(averageTrendDates(dates, 54, new Set(dates))).toEqual(dates); + }); +}); diff --git a/apps/www/test/scenarios.test.ts b/apps/www/test/scenarios.test.ts index 5e883eb6e8..bc03429427 100644 --- a/apps/www/test/scenarios.test.ts +++ b/apps/www/test/scenarios.test.ts @@ -523,7 +523,7 @@ describe("query parameters", () => { reportHref("/package-managers/clean", { date: null, fixture: null, - range: 30, + range: 120, from: null, to: null, hide: [], @@ -538,7 +538,7 @@ describe("query parameters", () => { it("a malformed value falls back to the default instead of erroring", async () => { const q = await loadReportParams(new URLSearchParams("range=7&scale=sideways&hide=")); - expect(q.range).toBe(30); + expect(q.range).toBe(120); expect(q.scale).toBe("linear"); expect(q.hide).toEqual([]); }); From d4ed39de61dae3255318548f7e4005b4e377e296 Mon Sep 17 00:00:00 2001 From: Jason Korol Date: Thu, 22 Oct 2026 15:57:57 -0400 Subject: [PATCH 3/9] feat: lead with performance over time and label its units Moves the chart above trend by tool, and swaps the sideways axis title for a caption above the plot that says what the values are (ms/pkg, x, %). --- apps/www/src/components/charts-echarts.tsx | 22 ++++++++++------------ apps/www/src/lib/outline.ts | 2 +- apps/www/test/scenarios.test.ts | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/www/src/components/charts-echarts.tsx b/apps/www/src/components/charts-echarts.tsx index 993ad441e8..a0e7c4f2f1 100644 --- a/apps/www/src/components/charts-echarts.tsx +++ b/apps/www/src/components/charts-echarts.tsx @@ -169,7 +169,7 @@ export const METRIC_LABELS: { readonly [M in Metric]: string } = { total: "Total time", change: "Change since start", baseline: "Relative to baseline", - "per-package": "Per package", + "per-package": "Per package (ms/pkg)", }; /** The mark a derived metric carries wherever it is chosen or drawn. */ @@ -324,7 +324,7 @@ export const TimeSeriesChart = ({ : metric === "baseline" ? `${v.toFixed(v < 10 ? 2 : 1)}×` : metric === "per-package" - ? `${v.toFixed(v < 10 ? 2 : 1)}ms` + ? `${v.toFixed(v < 10 ? 2 : 1)}ms/pkg` : formatDuration(v); const derived = metric !== "total"; const detailOn = useMemo(() => new Map(trend.map((d) => [d.date, d])), [trend]); @@ -495,12 +495,13 @@ export const TimeSeriesChart = ({ const measured = metric === "change" - ? "Change since the first run in range" + ? "Change since the first run in range (%)" : metric === "baseline" - ? `Time relative to ${baselineLabel ?? "the baseline"}` + ? `Time as a multiple of ${baselineLabel ?? "the baseline"}'s (×)` : metric === "per-package" - ? "Install time per package" - : yLabel; + ? "Install time per installed package (ms/pkg)" + : /* No unit here: a tick is in seconds or milliseconds and says which. */ + `${yLabel}, per ${section === "task-runners" ? "run" : "install"}`; const yTitle = `${measured}${derived ? " · derived" : ""}${axis === "log" ? " · log scale" : ""}${ range === null ? "" : range === "fit" ? " · fit to data" : " · zoomed" }`; @@ -996,11 +997,6 @@ export const TimeSeriesChart = ({ ...(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, @@ -1309,6 +1305,8 @@ export const TimeSeriesChart = ({ )} + {/* What the values are, above the plot where it is read first: not turned on its side beside it. */} +

{yTitle}

{drawn ? ( <> @@ -1442,7 +1440,7 @@ export const TimeSeriesChart = ({ (v.length === 10 ? shortDay(v) : v)} /> - + {/* * EvilCharts' brush, as its docs wire it: a miniature of every diff --git a/apps/www/src/lib/outline.ts b/apps/www/src/lib/outline.ts index 03056fb736..8a338445e4 100644 --- a/apps/www/src/lib/outline.ts +++ b/apps/www/src/lib/outline.ts @@ -59,8 +59,8 @@ export const buildOutline = (input: OutlineInput): OutlineSection[] => { title: measure, summary: `By tool, over time, and as raw numbers`, items: [ - chart("trend-by-tool", "Trend by tool"), chart("time-trend", "Performance over time"), + chart("trend-by-tool", "Trend by tool"), chart("time-bars", `Total ${measureNoun} time by tool`), ...(fixtureCount > 1 ? [chart("time-by-fixture", `Total ${measureNoun} time by fixture`)] diff --git a/apps/www/test/scenarios.test.ts b/apps/www/test/scenarios.test.ts index bc03429427..8d214e3f0b 100644 --- a/apps/www/test/scenarios.test.ts +++ b/apps/www/test/scenarios.test.ts @@ -207,11 +207,11 @@ describe("buildOutline", () => { expect(buildOutline({ ...base, fixtures: [] })).toEqual([]); }); - it("puts the trend cards, the trend, the bars and the table under time", () => { + it("puts the trend, the trend cards, the bars and the table under time", () => { const time = buildOutline(base).find((s) => s.id === "time"); expect(time?.items.map((i) => i.id)).toEqual([ - "trend-by-tool", "time-trend", + "trend-by-tool", "time-bars", "time-by-fixture", "time-table", From b1332c1b95c5426c7743f9b25b87643aff4c074b Mon Sep 17 00:00:00 2001 From: Jason Korol Date: Thu, 22 Oct 2026 15:58:09 -0400 Subject: [PATCH 4/9] feat: add a see performance over time button Sits under the lede and scrolls down to the chart. Also raises the anchor offset so headings land below the floating filter bar instead of under it. --- .../src/app/[section]/[[...params]]/page.tsx | 25 +++++++++++++------ apps/www/src/components/report-shell.tsx | 2 +- apps/www/src/components/report.tsx | 2 +- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/www/src/app/[section]/[[...params]]/page.tsx b/apps/www/src/app/[section]/[[...params]]/page.tsx index ef2258a190..f818571cc1 100644 --- a/apps/www/src/app/[section]/[[...params]]/page.tsx +++ b/apps/www/src/app/[section]/[[...params]]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Fragment, Suspense } from "react"; import { notFound, redirect } from "next/navigation"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, ChartLine } from "lucide-react"; import { InlineCode, Marked } from "@/components/inline-code"; import { Button } from "@/components/ui/button"; import { TimeSeriesChart } from "@/components/charts-echarts"; @@ -13,6 +13,7 @@ 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 { HeadingLink } from "@/components/heading-anchor"; import { PathSkeleton } from "@/components/path-skeleton"; import { PendingLink, PendingProvider, PendingRegion } from "@/components/report-pending"; import { @@ -637,14 +638,22 @@ const Report = async ({ } action={ - run.isLatest ? undefined : ( - - ) + {run.isLatest ? null : ( + + )} +
} /> {caveats.qualifier === null ? null : ( diff --git a/apps/www/src/components/report-shell.tsx b/apps/www/src/components/report-shell.tsx index ffc4b2597c..c8eb87ed01 100644 --- a/apps/www/src/components/report-shell.tsx +++ b/apps/www/src/components/report-shell.tsx @@ -167,7 +167,7 @@ export const ReportIntro = ({ /** 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. */ + /** Calls to action under the lede: the way to the trend, and on a past run the way to the latest. */ action?: ReactNode; }) => { const scenario = scenarioOf(section, variation); diff --git a/apps/www/src/components/report.tsx b/apps/www/src/components/report.tsx index f35b5573a1..be85a30e0b 100644 --- a/apps/www/src/components/report.tsx +++ b/apps/www/src/components/report.tsx @@ -15,7 +15,7 @@ import { HeadingAnchor, HeadingLink } from "@/components/heading-anchor"; * 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)]"; +const ANCHOR = "scroll-mt-[calc(var(--header-height)+4.5rem)]"; export const ReportSection = ({ id, From 59e812737fb6317e926c23cae3346ed87f4b9c3a Mon Sep 17 00:00:00 2001 From: Jason Korol Date: Thu, 22 Oct 2026 15:58:35 -0400 Subject: [PATCH 5/9] feat: add data mode Switch in the footer that hides the prose on report pages (intro, notes under figures, callouts, stat cards) and leaves the charts and tables. Spacing tightens and the page gets wider while it's on. The choice is kept in localStorage and set before paint so there's no flash. Adds @base-ui/react for the shadcn switch. --- apps/www/package.json | 1 + .../src/app/[section]/[[...params]]/page.tsx | 11 +++-- apps/www/src/app/globals.css | 18 +++++++++ apps/www/src/app/layout.tsx | 15 ++++++- apps/www/src/components/raw-results.tsx | 2 +- apps/www/src/components/report-shell.tsx | 28 +++++++++---- apps/www/src/components/report.tsx | 16 +++++--- apps/www/src/components/site-footer.tsx | 35 +++++++++------- apps/www/src/components/ui/switch.tsx | 31 ++++++++++++++ apps/www/src/components/view-toggle.tsx | 40 +++++++++++++++++++ vlt-lock.json | 24 +++++++++++ 11 files changed, 186 insertions(+), 35 deletions(-) create mode 100644 apps/www/src/components/ui/switch.tsx create mode 100644 apps/www/src/components/view-toggle.tsx diff --git a/apps/www/package.json b/apps/www/package.json index 1f3bc686fe..e49afe7597 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -15,6 +15,7 @@ "test": "vitest run" }, "dependencies": { + "@base-ui/react": "1.9.0", "@benchmarks/data": "workspace:*", "@benchmarks/types": "workspace:*", "@mdx-js/loader": "^3.1.1", diff --git a/apps/www/src/app/[section]/[[...params]]/page.tsx b/apps/www/src/app/[section]/[[...params]]/page.tsx index f818571cc1..69c4749ddf 100644 --- a/apps/www/src/app/[section]/[[...params]]/page.tsx +++ b/apps/www/src/app/[section]/[[...params]]/page.tsx @@ -657,7 +657,7 @@ const Report = async ({ } /> {caveats.qualifier === null ? null : ( -
+
@@ -665,7 +665,7 @@ const Report = async ({ )} {report.stats === null ? null : ( -
+
{report.stats.map((stat) => ( +
{caveats.hidden.body}{" "} @@ -693,7 +693,10 @@ const Report = async ({ )} {caveats.disclosure === null ? null : ( -

+

Disclosure. {caveats.disclosure}

)} diff --git a/apps/www/src/app/globals.css b/apps/www/src/app/globals.css index cafa54dd4b..5ef0777da7 100644 --- a/apps/www/src/app/globals.css +++ b/apps/www/src/app/globals.css @@ -21,6 +21,8 @@ */ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +/* Data mode (components/view-toggle.tsx). */ +@custom-variant data-mode (&:where([data-view="data"] *)); /* The snapshot banner is in the header while a past run is shown. */ :root:has([data-snapshot-banner]) { @@ -502,3 +504,19 @@ background-position: 0 0; } } + +/* Data mode (components/view-toggle.tsx): the charts, tables and numbers, without the prose around them. */ +[data-view="data"] [data-narrative] { + display: none; +} + +/* The finding is the page's h1, so it stays for a screen reader. */ +[data-view="data"] [data-narrative="heading"] { + display: block; + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/apps/www/src/app/layout.tsx b/apps/www/src/app/layout.tsx index 6503b5dec7..77af0fe2cd 100644 --- a/apps/www/src/app/layout.tsx +++ b/apps/www/src/app/layout.tsx @@ -70,6 +70,14 @@ const RootLayout = async ({ children }: { children: ReactNode }) => { className={`${sans.variable} ${mono.variable} ${pixel.variable} h-full antialiased`} suppressHydrationWarning > + + {/* The reader's Report or Data choice, on before paint (components/view-toggle.tsx). */} +