From dae17e180c325649c5f005dd683ac776493ea01a Mon Sep 17 00:00:00 2001 From: Will Manning Date: Thu, 29 Oct 2026 03:43:41 +0100 Subject: [PATCH 01/21] Plan bench PR 3: a folded landing page Co-Authored-By: Claude Opus 5.5 Signed-off-by: Will Manning --- .../docs/plans/2026-10-29-landing-option-b.md | 190 ++++++ .../docs/plans/2026-10-29-landing-pr3-plan.md | 605 ++++++++++++++++++ 2 files changed, 795 insertions(+) create mode 100644 bench/docs/plans/2026-10-29-landing-option-b.md create mode 100644 bench/docs/plans/2026-10-29-landing-pr3-plan.md diff --git a/bench/docs/plans/2026-10-29-landing-option-b.md b/bench/docs/plans/2026-10-29-landing-option-b.md new file mode 100644 index 0000000..58b6e8f --- /dev/null +++ b/bench/docs/plans/2026-10-29-landing-option-b.md @@ -0,0 +1,190 @@ + + +# bench: a folded landing page (option B) + +Status: design, 2026-10-29. Step 2 of 3 (step 1 is the toolchain move and +cleanup in PRs 1 and 2; step 3 is the cross-site visual redesign). + +## Why + +bench's landing page (`/`) shows 23 groups and 791 charts. Today every group +starts folded, but the page is still heavy and long: + +- each group shows its full summary card while folded, so the folded page is + 23 summary cards tall; +- every one of the 791 chart cards is server-rendered and hydrated at load, + with its listeners, even though no group is open; +- which groups are open lives only in the DOM, so a reload, Back from a chart + page or a shared link brings every group back folded. + +Will looks at charts on about 20% of visits and thinks most people do too. +Mikhail (a key user) browses with restrictive privacy settings, including no +WebGL, and wants pages to load very fast. Will: a short page of headlines is +acceptable if it loads fast and Back returns to the exact spot. Will chose +this option (B) from three mockups on 2026-10-28 (copy of the mockup: +`bench-landing-options-2026-10-28.html` in the session notes). + +## Goals + +- The folded page is short: one row per group, with the group's name, chart + count and a headline that says how Vortex compares to Parquet. +- Nothing chart-related loads or hydrates until a group is opened. +- Opening a group shows its full summary card, its toolbar and its charts in + place, exactly as today. +- The URL records which groups are open. Reload, Back from a chart page and a + shared link reopen them before the first paint, and the browser's scroll + restoration lands on the same spot. +- It works with JavaScript-heavy features limited: no WebGL anywhere (charts + stay Chart.js on 2D canvas), and the folded page is readable before any + client JavaScript runs. + +## Non-goals + +- Visual redesign: colors, fonts and layout polish are step 3. +- New API routes, new queries, or changes to the summary math. The headline is + derived from the `summary` each group already carries. +- Changing the chart page (`/chart/{slug}`), the APIs, caching rules or the + loading machinery (`lib/chart-store` queues, bundles, full history). +- Removing the global engine/format filter, the group toolbar or permalinks. + +## Anti-goals + +- A folded page that hides a result that looks bad for Vortex. The headline + shows slower results in words ("4.0× slower"), not only in color. +- A flash of folded content before reopening groups on reload or Back. +- Breaking existing links: old `#group-anchor` permalinks and + `?group=…&hide=…&show=…` URLs keep working. + +## Decided by Will + +- Option B: one page, every group folded with its headline numbers, opening a + group loads its charts in place, open groups kept in the URL. +- Fast loads and exact Back behavior matter more than showing every chart at + once; Mikhail's preferences are a direction, not hard rules. + +## My calls (Will was away; each is easy to change) + +1. **Headline content**, computed by a pure server-side function from + `Group.summary`: + - `queryBenchmark`: per engine that ran both, `score(engine:parquet) / + score(engine:vortex-file-compressed)`, shown as "DuckDB 2.5× faster · + DataFusion 2.3× faster". Within ±5% it reads "≈ same"; below 1 it reads + "N× slower". Groups without a Parquet run read "Vortex only". + - `compression`: "encode 1.6× · decode 5.1× faster than Parquet" from the + `vortex-file-compressed` ratios. + - `compressionSize`: "7% larger than Parquet" (or "smaller") from `ratio`. + - `randomAccess`: "hot 60× · cold 32× faster than Parquet" from the hot and + cold scores. + - `vectorSearch`: no Parquet series; show the fastest flavor ("fastest: + vortex-turboquant"). + - Partial coverage (`measured < total` for either side) adds "(partial)". + - If a summary is missing or the needed series are absent, the row shows no + headline rather than a misleading number. +2. **The full summary card moves inside the open body**, above the group + toolbar, so the folded row is one line on desktop (two on a phone). +3. **Chart cards render only for open groups.** The group body is a client + component that mounts `GroupToolbar` and the chart grid when its group is + open and unmounts them on close (today's close already aborts fetches). + Bundles fetched in this session stay cached, so reopening is instant. The + page-wide chart index used for loading priority keeps today's numbering. +4. **URL state in the fragment, not the query.** `#tpc-h-nvme-sf-10` (one + group) or `#tpc-h-nvme-sf-10,clickbench` (several), using the existing + anchors from `lib/anchor.ts`. A fragment never reaches the CDN, so `/` keeps + one cache entry. Every open or close updates the fragment with + `history.replaceState` (no history entry per click). Expand All writes every + anchor; Collapse All clears the fragment. An old single-anchor permalink + still opens and scrolls to that group. +5. **Open before first paint.** A small inline script right after the groups' + markup reads the fragment and sets `open` on the matching `
` before + the browser paints, so reload and Back show open groups immediately and the + browser's own scroll restoration lands correctly. The React side reads the + same state on hydration. The script is the only inline script; it uses no + external input other than the fragment, which it matches against known + anchors. +6. **Back from a chart page.** Chart titles stay plain links (a full + navigation). Back reloads `/` from the CDN with the fragment, and step 5 + restores the open groups. Fixed-height chart placeholders (already 420/450 + px) keep positions stable while charts load. Cards classified empty after + the bundle arrives are hidden today, which can shift content below them; + keep that behavior, but measure it (see Proof). +7. **No change to `Cache-Control`.** The page keeps `no-store` for browsers and + `Vercel-CDN-Cache-Control` for the CDN. Whether Chrome's back/forward cache + applies is measured, not assumed; call 8 makes Back correct either way. +8. **The page restores scroll itself, by element, not by pixel.** The server + can't see the fragment, so an open group's body (toolbar and chart cards, + with their fixed-height placeholders) exists only after hydration; the + browser's own restoration would land against a shorter page. On `/`, set + `history.scrollRestoration = 'manual'`. On `pagehide`, and before following a + chart link, save to `sessionStorage` (keyed by path and fragment) the element + nearest the top of the viewport — a chart card (`data-chart-slug`) or else a + group section (`data-group-slug`) — and its offset from the viewport top. + On reload or Back (`navigation.type` is `reload` or `back_forward`), restore + by scrolling that element to the saved offset once the open groups' bodies + have mounted, and once more after their bundles settle (empty-card hiding + can shift content), unless the user scrolled in between. "Same spot" means + the same element at the same offset within a few pixels, not an identical + `scrollY`. Layout shift is measured and reported, not a gate. If nothing was + saved, fall back to the fragment scroll in call 9. +9. **When the page scrolls to the fragment.** Only on a fresh visit + (`navigation.type === 'navigate'` and nothing saved): open every listed + group and scroll to the first one, as today's `jumpToLocationHash` does for + a single anchor. Reload and Back never fragment-scroll; call 8 owns them. A + malformed fragment is ignored (today's `decodeURIComponent` can throw). +10. **What survives close and reopen.** Closing unmounts the group's chart + cards, so per-chart zoom, the scope slider and a loaded full history are + lost; reopening starts those charts fresh (the session's bundle cache makes + it instant). Group-level state survives: the series filter and group Y live + in the group store, and `initGroupFilter` seeds from the URL only once per + group per page load, so a remount does not overwrite the user's overrides. +11. **Summary card placement.** `SummaryCard` stays a server component, + rendered inside the `
` (hidden natively while folded, shown when + open, and readable without JavaScript). Smaller landing HTML is a side + effect of dropping 791 chart shells, not a success criterion. +12. **Group nav and permalinks.** A group-nav click adds that group to the open + set (opens it, scrolls to it, updates the fragment). A permalink copies a + link to that one group (its anchor plus its own `group`/`hide`/`show` + overrides, as today) and no longer rewrites the address bar, which now + holds the open set. Tests that pin the old outputs are rewritten. + +## Constraints + +- Global constraints from the PR series (`bench/docs/plans/ + 2026-10-28-toolchain-and-cleanup.md`): supply-chain gates, Biome style for + bench, SPDX headers, DCO sign-off, no settings changes without Will. +- The API responses must not change; the parity harness's API and chart-page + comparisons stay at 0 mismatches. Landing-page screenshots change by design. +- Keep `
`/`` for groups: native keyboard support, and the + chart islands' existing `toggle` contract. +- Accessibility: the headline is text inside the ``; nothing new is + conveyed by color alone; no new focusable element without keyboard support. + +## Proof + +1. Unit tests for the headline function on every summary variant, including + slower, ≈ same, partial coverage, missing series and Vortex-only. +2. Component tests: folded rows render no chart markup; opening mounts the + grid and fetches one bundle; closing unmounts and aborts; the fragment + updates on open, close, Expand All and Collapse All; an old single-anchor + permalink still opens and scrolls. +3. Parity harness: 0 API and chart-page mismatches against PR 2's head. New + landing screenshots replace the old baselines; record them in the PR. +4. Measured with the parity servers and Playwright, before and after (a + throwaway script on the local production dump; numbers go in the PR body): + landing HTML bytes, JavaScript executed, number of hydrated islands, first + contentful paint, Total Blocking Time (as the proxy for time to + interactive), and layout shift when opening a group and when coming Back + with two groups open; plus whether Back restored the same element at the + same offset. PR 3 is stacked on PR 2's branch (`claude/bench-cleanup`), and + parity's base is that branch's head. +5. Will's smoke test in the preview: reload and Back from a chart page land + on the same spot with the same groups open. + +## Left to the builder + +- Component boundaries for the group body and the URL-state helper. +- The exact headline wording within the rules above. +- How Expand All behaves with 23 groups (it may open them all at once, as + today, through the existing bundle queue). diff --git a/bench/docs/plans/2026-10-29-landing-pr3-plan.md b/bench/docs/plans/2026-10-29-landing-pr3-plan.md new file mode 100644 index 0000000..0201888 --- /dev/null +++ b/bench/docs/plans/2026-10-29-landing-pr3-plan.md @@ -0,0 +1,605 @@ + + +# bench PR 3: a folded landing page (option B) — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** bench's landing page shows one row per group with a headline that compares Vortex with Parquet. A group's toolbar and charts mount only while the group is open. The open groups are kept in the URL fragment. Reload and Back return to the same element at the same offset. + +**Architecture:** One branch, `claude/bench-landing`, stacked on PR 2's `claude/bench-cleanup`, with one commit per task. +- `details.open` stays the only source of truth for "open", so the chart islands keep their `toggle` contract. +- The fragment mirrors it. A document-level `toggle` listener writes the fragment with `history.replaceState`. A constant inline script placed after the groups' markup reads it and sets `open` before first paint. +- A client `GroupBody` inside each `
` mounts `GroupToolbar` and the chart islands only while the group is open. +- A client `LandingState` keeps the fragment in step and scrolls to it on a fresh visit. On Reload or Back it puts the saved element back at its saved offset. + +**Tech Stack:** Bun 1.4.3 workspace, Next.js 16.4.1 App Router, React 19.3.1, TypeScript 7, Biome 2.5, Vitest 5 with jsdom 30, Playwright 1.64 (parity and the throwaway measurement), Postgres 16 (Homebrew `postgresql@16`). + +**Spec:** `bench/docs/plans/2026-10-29-landing-option-b.md` (the setup step copies it from `.superpowers/sdd/pr3-landing-option-b-design.md`). Its "My calls" are decisions. The style reference is `bench/docs/plans/2026-10-29-cleanup-pr2-plan.md`. + +**Starts from:** PR 2's six tasks are pushed on `claude/bench-cleanup`. Paths below are after PR 2's split: +- `ensureGroupBundle`, `abortGroupBundle`, `resetPayloadCache` and `BundleResult` are in `lib/chart-store/bundles.ts`. +- `bundleQueue` is in `lib/chart-store/queues.ts`. +- `initGroupFilter`, `getGroupSnapshot` and `toggleGroupSeries` are in `lib/chart-store/group-store.ts`. +- All of them are imported from `@/lib/chart-store`. +- The `Chart` island and its mount effect stay in `components/Chart.tsx` (before the split, the priority line was around L1924 of the old file). + +TypeScript has `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` (PR 2 Task 5). `BENCH_TEST_DATABASE_URL` runs the integration suite against Homebrew Postgres on port 55433 (`bench/web/README.md`, "Local development"). + +Setup: +- [ ] Work in a fresh worktree, because another session uses the PR 2 checkout: `git fetch origin && git worktree add -b claude/bench-landing ../bench-landing origin/claude/bench-cleanup`. +- [ ] Copy the design to `bench/docs/plans/2026-10-29-landing-option-b.md` and save this plan as `bench/docs/plans/2026-10-29-landing-pr3-plan.md`. Commit: `Plan bench PR 3: a folded landing page`. +- [ ] Start the test database as the README says and export `BENCH_TEST_DATABASE_URL`. Run `bun run --cwd bench/web build`, then `bun run --cwd bench/web test`. Every file passes and nothing is skipped. Record the count. + +## Global Constraints + +- "The API responses must not change; the parity harness's API and chart-page comparisons stay at 0 mismatches. Landing-page screenshots change by design." +- "No change to `Cache-Control`." `bench/web/vercel.json` does not change. +- Non-goals: "New API routes, new queries, or changes to the summary math. The headline is derived from the `summary` each group already carries." "Changing the chart page (`/chart/{slug}`), the APIs, caching rules or the loading machinery (`lib/chart-store` queues, bundles, full history)." "Removing the global engine/format filter, the group toolbar or permalinks." "Visual redesign: colors, fonts and layout polish are step 3." +- Anti-goals: "A folded page that hides a result that looks bad for Vortex. The headline shows slower results in words ("4.0× slower"), not only in color." "A flash of folded content before reopening groups on reload or Back." "Breaking existing links: old `#group-anchor` permalinks and `?group=…&hide=…&show=…` URLs keep working." +- "Keep `
`/`` for groups: native keyboard support, and the chart islands' existing `toggle` contract." +- "Accessibility: the headline is text inside the ``; nothing new is conveyed by color alone; no new focusable element without keyboard support." +- "no WebGL anywhere (charts stay Chart.js on 2D canvas), and the folded page is readable before any client JavaScript runs." +- "The script is the only inline script; it uses no external input other than the fragment, which it matches against known anchors." The layout's theme and font scripts were there before it. It is the only new inline script. +- The supply-chain gates must not get weaker: "the 14-day cooldown, explicit overrides, the lifecycle-script allowlist, SHA-pinned actions, `ubuntu-24.04` runners". This PR adds no dependency. +- The required check is named `Bench: format, lint, build, test`. "Settings changes need Will's confirmation first." Node 24 stays. Vitest stays. +- bench keeps its style: single quotes, trailing commas everywhere, 100 columns, 2-space indent. `useExhaustiveDependencies` is an error in bench, so a deliberate dependency list needs a `biome-ignore` with its reason. +- Every commit uses `git commit -s` and ends with `Co-Authored-By: Claude Opus 5.5 `. Squash merges only. +- New code files under `bench/` start with `// SPDX-License-Identifier: Apache-2.0` and `// SPDX-FileCopyrightText: Copyright the Vortex contributors`. Markdown files use CC-BY-4.0. + +## Review Focus + +1. **Expand All, Collapse All or fast toggling on the 23-group page.** Browsers rate-limit `history.replaceState`, and Safari throws a `SecurityError` past its limit. One click must write the URL once, and a refused write must never stop groups from opening. Task 3 pins this with `writes the fragment once per Expand All and Collapse All` and `keeps opening groups when the browser refuses a URL write`. +2. **Closing a group while its bundle is loading, in a real browser.** The browser runs microtasks between `toggle` listeners, so React can unmount the chart islands before their own close handler runs. jsdom does not run microtasks there, so the existing suites cannot see this. The request must still be cancelled, and a reopen must issue a fresh one. Task 2 pins this with `closing unmounts the body and aborts the bundle, even when no chart island hears the toggle`. +3. **A browser that blocks storage** (Mikhail's privacy settings), where reading `sessionStorage` throws. The page must work, and Back must fall back to the fragment scroll. Task 4 pins this with `saves and reads nothing, without throwing, when storage is blocked` and `falls back to the fragment scroll on Back when storage is blocked`. +4. **Back when the saved chart is no longer on the page.** The page must land on the first listed group, not stay at the top. Task 4 pins this with `falls back to the fragment scroll when the saved element is gone`. +5. **A browser that refuses the clipboard.** The permalink no longer writes the address bar, so it must not say "Link copied" when nothing was copied. Task 3 pins this with `does not claim a copy the browser refused`. + +--- + +### Task 1: The folded row's headline + +**Files:** +- Create: `bench/web/lib/headline.ts`, `bench/web/lib/headline.test.ts` +- Modify: `bench/web/components/GroupSection.tsx` (headline in the summary row), `bench/web/components/GroupSection.test.tsx`, `bench/web/app/globals.css` + +**Interfaces:** +- Consumes: `Summary`, `SeriesRanking` from `@/lib/summary` (unchanged); `displayFormat` from `@/lib/chart-format`. +- Produces in `lib/headline.ts`: + - `export type HeadlineTone = 'better' | 'worse' | 'same' | 'neutral'` + - `export interface HeadlinePart { text: string; tone: HeadlineTone }` + - `export interface Headline { parts: HeadlinePart[]; suffix: string | null; partial: boolean }` + - `export function groupHeadline(summary: Summary | undefined): Headline | null` + - `export function headlineText(headline: Headline): string`. This joins the parts with `' · '`, appends `' ' + suffix` when there is one, and appends `' (partial)'` when `partial` is true. + +- [ ] **Step 1: Write the failing tests** in `lib/headline.test.ts`: + +```ts +const VX = 'vortex-file-compressed'; +const r = (name: string, score: number, measured = 10, total = 10): SeriesRanking => + ({ name, score, totalRuntime: 1, measured, total }); +const vx = (engine: string, score = 1, measured = 10) => r(`${engine}:${VX}`, score, measured); +const pq = (engine: string, score: number, measured = 10) => r(`${engine}:parquet`, score, measured); +const query = (...rankings: SeriesRanking[]): Summary => + ({ type: 'queryBenchmark', title: 'q', rankings, explanation: 'e' }); +const compression = (...rows: [string, 'encode' | 'decode', number][]): Summary => ({ + type: 'compression', title: 'Write Throughput', explanation: 'e', + rankings: rows.map(([name, operation, ratio]) => ({ name, operation, ratio })), +}); +const ra = (hot: SeriesRanking[], cold: SeriesRanking[]): Summary => + ({ type: 'randomAccess', hotRankings: hot, coldRankings: cold, explanation: 'e' }); +const vs = (...rankings: SeriesRanking[]): Summary => + ({ type: 'vectorSearch', title: 'v', rankings, explanation: 'e' }); +const line = (s: Summary | undefined) => { const h = groupHeadline(s); return h && headlineText(h); }; + +it('compares Vortex with Parquet per engine, DuckDB first', () => { + const s = query(vx('datafusion'), pq('duckdb', 2.5), pq('datafusion', 2.3), vx('duckdb'), r('duckdb:vortex-compact', 0.5)); + expect(line(s)).toBe('DuckDB 2.5× · DataFusion 2.3× faster than Parquet'); + expect(groupHeadline(s)?.parts.map((p) => p.tone)).toEqual(['better', 'better']); +}); +it.each([ + [2.5, 'DuckDB 2.5× faster than Parquet'], [0.25, 'DuckDB 4.0× slower than Parquet'], + [1.05, 'DuckDB ≈ same vs Parquet'], [0.95, 'DuckDB ≈ same vs Parquet'], + [1.06, 'DuckDB 1.1× faster than Parquet'], [0.94, 'DuckDB 1.1× slower than Parquet'], + [9.96, 'DuckDB 10× faster than Parquet'], [60.4, 'DuckDB 60× faster than Parquet'], +])('reads a Parquet-to-Vortex score ratio of %s as "%s"', (ratio, expected) => { + expect(line(query(vx('duckdb'), pq('duckdb', ratio)))).toBe(expected); +}); +it('gives each engine its own verdict when they disagree', () => { + expect(line(query(vx('duckdb'), pq('duckdb', 2.5), vx('datafusion', 2), pq('datafusion', 1)))) + .toBe('DuckDB 2.5× faster · DataFusion 2.0× slower vs Parquet'); +}); +it('marks partial coverage', () => { + expect(line(query(vx('duckdb', 1, 9), pq('duckdb', 2.5)))).toBe('DuckDB 2.5× faster than Parquet (partial)'); +}); +it('reads "Vortex only" when no engine ran Parquet', () => { + expect(line(query(vx('datafusion'), r('duckdb:vortex-compact', 1)))).toBe('Vortex only'); + expect(line(query(vx('datafusion', 1, 9)))).toBe('Vortex only (partial)'); +}); +it('leaves out an engine that ran only one side', () => { + expect(line(query(vx('duckdb'), pq('duckdb', 2.5), vx('datafusion')))).toBe('DuckDB 2.5× faster than Parquet'); +}); +it.each([ + ['no summary', undefined], + ['only Parquet', query(pq('duckdb', 1))], + ['an unmeasured Vortex series', query(vx('duckdb', 1, 0), pq('duckdb', 2))], + ['a zero score', query(vx('duckdb', 0), pq('duckdb', 2))], + ['compression without Vortex', compression(['parquet', 'encode', 1])], + ['ratios that are not positive and finite', compression([VX, 'encode', 0], [VX, 'decode', Number.NaN])], + ['an empty size summary', { type: 'compressionSize', title: 's', rankings: [], explanation: 'e' }], + ['empty random-access rankings', ra([], [])], + ['unmeasured vector-search flavors', vs(r('vortex-turboquant', 1, 0))], +])('shows no headline for %s', (_case, summary) => { + expect(groupHeadline(summary as Summary | undefined)).toBeNull(); +}); +it('reads encode and decode throughput against Parquet', () => { + expect(line(compression([VX, 'encode', 1.6], [VX, 'decode', 5.1], ['parquet', 'encode', 1], ['lance', 'decode', 0.6]))) + .toBe('encode 1.6× · decode 5.1× faster than Parquet'); + expect(line(compression([VX, 'encode', 0.5], [VX, 'decode', 5.1]))).toBe('encode 2.0× slower · decode 5.1× faster vs Parquet'); +}); +it.each([ + [1.07, '7% larger than Parquet', 'worse'], [0.85, '15% smaller than Parquet', 'better'], + [1.004, 'same size as Parquet', 'same'], [2.4, '2.4× larger than Parquet', 'worse'], +])('reads a size ratio of %s as "%s"', (ratio, expected, tone) => { + const h = groupHeadline({ type: 'compressionSize', title: 's', explanation: 'e', + rankings: [{ name: VX, minRatio: ratio, ratio, maxRatio: ratio, compressionRatio: 5 }] }); + expect(h && headlineText(h)).toBe(expected); + expect(h?.parts[0]?.tone).toBe(tone); +}); +it('reads hot and cold random access against Parquet', () => { + expect(line(ra([r(VX, 1), r('parquet', 60.4)], [r(VX, 1), r('parquet', 32)]))).toBe('hot 60× · cold 32× faster than Parquet'); + expect(line(ra([r(VX, 1), r('parquet', 60.4, 1, 2)], []))).toBe('hot 60× faster than Parquet (partial)'); +}); +it('names the fastest measured vector-search flavor', () => { + expect(line(vs(r('vortex-turboquant', 1), r('vortex-default', 1.4)))).toBe('fastest: vortex-turboquant'); + expect(line(vs(r('vortex-turboquant', 1, 0), r('vortex-default', 1.4)))).toBe('fastest: vortex-default'); + expect(groupHeadline(vs(r('vortex-turboquant', 1)))?.parts[0]?.tone).toBe('neutral'); +}); +``` + +- [ ] **Step 2: Run** `bun run --cwd bench/web test lib/headline.test.ts`. Expect FAIL: the module does not exist. +- [ ] **Step 3: Implement** `groupHeadline` and `headlineText`. The rules: + - A series counts only when `measured > 0`. A part whose ratio is not a positive finite number is dropped. When no parts remain, return `null`. Never return a misleading number. + - **Ratio `r`, read as "how many times faster Vortex is".** + - `queryBenchmark`: per engine, `score(engine:parquet) / score(engine:vortex-file-compressed)`. + - `randomAccess`: the same for `parquet` and `vortex-file-compressed`, once for `hotRankings` (label `hot`) and once for `coldRankings` (label `cold`). + - `compression`: the `vortex-file-compressed` row's `ratio`, once for encode (label `encode`) and once for decode (label `decode`). + - **Verdict.** `0.95 <= r <= 1.05` gives `≈ same` (tone `same`). `r > 1.05` gives ` faster` (`better`) with `m = r`. `r < 0.95` gives ` slower` (`worse`) with `m = 1 / r`. A multiple `m` prints as `${m.toFixed(1)}×` below 9.95, otherwise `${Math.round(m)}×`. + - **Multi-part families (query, random access, compression).** If every part is `better`, part texts are `label m` and the suffix is `faster than Parquet`. If every part is `worse`, the same with `slower than Parquet`. Otherwise each part is `label verdict` and the suffix is `vs Parquet`. + - **Query engines.** Labels are `{ duckdb: 'DuckDB', datafusion: 'DataFusion' }`, and an unknown engine falls back to its raw key. Order is `duckdb`, then `datafusion`, then the rest by code-unit order. Only engines with both series measured get a part. If engines have Vortex but none has Parquet, the single part is `Vortex only` (`neutral`, suffix `null`). + - Random access also reads `Vortex only` when Vortex was measured and Parquet was not. + - **`compressionSize`.** Use the Vortex row's `ratio` with `pct = Math.round(Math.abs(r - 1) * 100)`, as one part with suffix `null`: + - `r >= 2`: ` larger than Parquet` (`worse`). + - `pct === 0`: `same size as Parquet` (`same`). + - `r > 1`: `${pct}% larger than Parquet` (`worse`). + - otherwise: `${pct}% smaller than Parquet` (`better`). + - **`vectorSearch`.** The single part is `fastest: ${displayFormat(name)}` (`neutral`), from the measured ranking with the lowest score (first on ties). + - **Partial.** `partial` is true when any series behind a part has `measured < total`. +- [ ] **Step 4: Run** the file again. Expect PASS. +- [ ] **Step 5: Render the headline.** In `GroupSection`, compute `groupHeadline(group.summary)`. When it is not null, render it as the last child of `.group-summary-row`, after the permalink. The markup is ``, holding one `` per part, separated by the text `" · "`, then the suffix and `" (partial)"` as text. Its text content must equal `headlineText`. Add to `GroupSection.test.tsx`: + - `renders the headline as text inside the summary row`. Use a `TPCH` fixture: a `queryBenchmark` summary with `duckdb:vortex-file-compressed` score 1 and `duckdb:parquet` score 2.5, both 22 of 22. Take `row = html.slice(html.indexOf(''))`. It contains `class="group-headline"` and `data-tone="better"`, and `row.replace(/<[^>]+>/g, '')` contains `DuckDB 2.5× faster than Parquet`. + - `renders no headline when the summary has no Vortex series`. The existing `RANDOM_ACCESS` fixture ranks a series named `vortex`, so its HTML must not contain `group-headline`. +- [ ] **Step 6: Style it.** In `globals.css`, add `flex-wrap: wrap` to `.group-summary-row`. Add `.group-headline { margin-left: auto; color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }`. Inside the existing `@media (max-width: 768px)` block, add `.group-headline { flex-basis: 100%; margin-left: 0; }`. That gives one line on desktop and two on a phone. Tone colors wait for step 3; the words carry the meaning. +- [ ] **Step 7: Verify.** `bun run --cwd bench/web test lib/headline.test.ts components/GroupSection.test.tsx` passes. `bun run --cwd bench/web lint`, `bun run --cwd bench/web format:check` and `bunx tsc --noEmit -p bench/web` are clean. +- [ ] **Step 8: Commit:** `Show a one-line headline on each folded bench group`. + +### Task 2: Mount a group's body only while it is open + +**Files:** +- Create: `bench/web/lib/group-bodies.ts`, `bench/web/lib/group-bodies.test.ts`, `bench/web/components/GroupBody.tsx`, `bench/web/components/GroupBody.test.tsx` +- Modify: `bench/web/lib/chart-format.ts` and `lib/chart-format.test.ts` (`chartPriority`); `bench/web/components/Chart.tsx` (one line: use `chartPriority`); `components/GroupSection.tsx` and its test; `components/GroupToolbar.tsx` (drop the seeding effect and props); `components/SummaryCard.tsx` (doc comment only); `app/globals.css` +- Modify (setup markup only): `components/Chart.lazy-hydration.test.tsx`, `Chart.loading.test.tsx`, `Chart.lifecycle.test.tsx`, `Chart.empty-window.test.tsx`. Also add one `GroupBody` test to `Chart.lazy-hydration.test.tsx`. +- Modify: `bench/web/lib/server-smoke.test.ts` + +**Interfaces:** +- Consumes: from `@/lib/chart-store`, `ensureGroupBundle(groupSlug: string, priority: number): Promise`, `abortGroupBundle(groupSlug: string): void`, `initGroupFilter(slug, hidden, shown)`, `bundleQueue`, `resetPayloadCache()`. Also `Chart` (`ChartIslandProps`) and Task 1's GroupSection. +- Produces: + - `lib/chart-format.ts`: `export function chartPriority(index: number): number`, which is `index === 0 ? 0 : -index` (`+0` for the first chart). + - `lib/group-bodies.ts`: + - `export interface GroupBodiesReady { mounted: Promise; settled: Promise }` + - `export function noteGroupBodyOpen(groupSlug: string, bundle: Promise): void` + - `export function groupBodiesReady(groupSlugs: readonly string[]): GroupBodiesReady`. `mounted` resolves once every listed slug has noted. `settled` resolves after that, once each noted bundle has settled; a rejection counts as settled. An empty list resolves at once. + - `export function seedGroupFilterOnce(groupSlug: string, hiddenSeries: readonly string[], shownSeries: readonly string[]): void`. It calls `initGroupFilter` the first time per slug per page load. + - `export function resetGroupBodies(): void` (test-only; clears both maps). + - `components/GroupBody.tsx` (`'use client'`): + - `export interface GroupBodyProps { groupSlug: string; charts: ChartLink[]; startIndex: number; universe: FilterUniverse; initialHiddenSeries: string[]; initialShownSeries: string[] }` + - `export function GroupBody(props: GroupBodyProps)` + - `GroupToolbar` props become `{ groupSlug: string; universe: FilterUniverse }`. + - DOM contract: `section.group-details > details.group-disclosure > (summary.group-summary, SummaryCard, div.group-body)`. While the group is open, `div.group-body` holds `section.group-toolbar` and `div.chart-grid > section.chart-card*`. + +- [ ] **Step 1: Write the failing tests.** + - In `lib/group-bodies.test.ts`: + - `resolves mounted once every listed group has noted its body, and settled after their bundles`. Note `a` with a resolved promise: nothing fires. Note `b` with a pending promise: only `mounted` fires. Resolve it: `settled` fires. + - `treats a failed bundle as settled`. + - `resolves at once for no groups, and counts notes made before the call`. + - `seeds a group's filter once per page load`. `seedGroupFilterOnce('g', ['a'], [])` makes `getGroupSnapshot('g').hiddenSeries` equal `['a']`. Then `toggleGroupSeries('g', 'a')` and seed again: still `[]`. + - In `lib/chart-format.test.ts`: `ranks the first chart at +0 and later charts by their negated index`. `Object.is(chartPriority(0), 0)` is true, and `chartPriority(7)` is `-7`. + - In `components/GroupBody.test.tsx` (jsdom). Mock `@/components/Chart` with an async factory: `const { createElement } = await import('react')`, and a `Chart` that renders `createElement('section', { className: 'chart-card', 'data-chart-slug': slug, 'data-chart-index': index })`. + - Render `` into a `#body` div inside `
g…
`. + - Toggle like the existing suites do: set `details.open`, then `dispatchEvent(new Event('toggle'))` inside `act`. + - Stub `fetch` to record each URL and `init.signal`. Group requests never resolve unless the test resolves them. + - In `beforeEach`, call `resetPayloadCache()`, `resetGroupBodies()` and `initGroupFilter('g', [], [])`. + +```tsx +it('renders an empty body while folded', () => { + expect(renderToStaticMarkup()).toBe('
'); +}); +it('mounts the toolbar and one card per chart, with page-wide indices, when the group opens', … + expect(body.querySelector('[data-role="group-toolbar"]')).not.toBeNull(); + expect([...body.querySelectorAll('.chart-card')].map((c) => c.dataset.chartIndex)).toEqual(['5', '6']); +it('mounts at once when the group is already open at hydration', … //
before render + expect(body.querySelectorAll('.chart-card')).toHaveLength(2); +it("requests the group bundle once, at its first chart's priority", … + expect(schedule).toHaveBeenCalledTimes(1); expect(schedule.mock.calls[0]?.[1]).toBe(-5); + expect(urls).toEqual(['/api/group/g?n=100']); +it('closing unmounts the body and aborts the bundle, even when no chart island hears the toggle', … + expect(body.querySelectorAll('.chart-card')).toHaveLength(0); expect(signals[0]?.aborted).toBe(true); +it('reopening after a successful bundle fetches nothing', … // resolve with { name: 'g', charts: [] }, close, reopen + expect(urls).toHaveLength(1); +it("seeds the URL's series overrides once, so a reopen keeps the user's change", … + expect(getGroupSnapshot('g').hiddenSeries).toEqual(['duckdb:parquet']); + toggleGroupSeries('g', 'duckdb:parquet'); /* close, reopen */ expect(getGroupSnapshot('g').hiddenSeries).toEqual([]); +it('tells the scroll restorer when its body has mounted and its bundle has settled', … + // after open: await groupBodiesReady(['g']).mounted resolves; .settled resolves only after the bundle response +``` + +- [ ] **Step 2: Run** `bun run --cwd bench/web test lib/group-bodies.test.ts lib/chart-format.test.ts components/GroupBody.test.tsx`. Expect FAIL: the modules and exports are missing. +- [ ] **Step 3: Implement `lib/group-bodies.ts` and `chartPriority`.** In `Chart.tsx`'s mount effect, `const priority = chartPriority(index);` replaces the inline expression and keeps its comment. +- [ ] **Step 4: Implement `GroupBody`.** + - `useState(false)` for `open`, so hydration matches the server's empty `
`. While open, it renders `` and `
` with ``. + - Mount effect: `seedGroupFilterOnce(groupSlug, initialHiddenSeries, initialShownSeries)`. + - Layout effect on mount: + - Find `ref.current.closest('details.group-disclosure')`. Without one, the body stays closed. + - Set `open` from `details.open`, because the pre-paint script (Task 3) may already have opened it. + - Listen for `toggle`. On close, call `abortGroupBundle(groupSlug)` first, then `setOpen(false)`. Comment why: the browser runs microtasks between listeners, so React can unmount the islands and remove their `toggle` listeners before those run. On open, `setOpen(true)`. + - Effect on `[open, groupSlug, startIndex]`: when open, `noteGroupBodyOpen(groupSlug, ensureGroupBundle(groupSlug, chartPriority(startIndex)))`. It runs after the islands' own mount effects, so it joins their in-flight bundle at the same priority and adds no request. It has no cleanup, because a StrictMode replay must not abort. +- [ ] **Step 5: Restructure `GroupSection`.** + - Inside `
`, after the ``, render `` and then ``. + - Nothing follows the `
` inside the section. Rewrite the component's doc comment to match. + - `GroupToolbar` loses its `initGroupFilter` effect and the two `initial*` props. Its doc comment now says it mounts only while the group is open. + - `SummaryCard`'s doc comment: the card renders inside the group's `
`, so it shows when the group is open, with or without JavaScript. +- [ ] **Step 6: Update the CSS.** + - Delete `.group-disclosure:not([open]) ~ .chart-grid`, `.group-disclosure:not([open]) ~ .group-toolbar` and the dead `body.bench-prebuilding …` rule. + - Retarget `.group-details > .chart-grid` (both the desktop and the 768px rule) and `.group-details > .chart-grid .chart-card:last-child` to `.group-body > .chart-grid`. + - Fix the `.group-toolbar` comment that cites the sibling rule. + - `grep -nE ':not\(\[open\]\) ~|bench-prebuilding|group-details > \.chart-grid' bench/web/app/globals.css` prints nothing. +- [ ] **Step 7: Update the tests that pin the old shape.** + - `GroupSection.test.tsx`: + - Replace `renders the summary card inside the section` with `renders the summary card inside the disclosure, after the summary row`. The index of `benchmark-scores-summary--random-access` lies between `
` and `
`. + - Replace `renders one chart-card shell per chart …` with `renders an empty group body and no chart or toolbar markup while folded`. The HTML contains `
` and contains neither `class="chart-card"`, `data-role="group-toolbar"` nor `` pin. + - In the eight setup strings of the four `Chart.*` suites (lazy-hydration ×4, loading, lifecycle ×2, empty-window), move the `.chart-grid` inside the `
`, wrapped in `
`: + +```ts +'
g' + +`
${mounts}
` +``` + + No assertion changes. Every one still passes, because the island finds its disclosure through `closest('.group-details')`. + - In the `PR-5.0.97 group-bundle hydration` describe of `Chart.lazy-hydration.test.tsx`, add `GroupBody mounts islands only while open; they share one bundle, and closing aborts it`. + - Use the real `Chart`, `MockIO`, and the never-resolving, signal-recording fetch from `closing the group aborts the in-flight bundle`. + - Render `` inside a closed `
`. + - While closed: 0 `.chart-card` and no fetch. After opening: 3 cards, 3 `MockIO` instances and `fetchCalls` equal to `['/api/group/g?n=100']`. After closing: 0 cards and the bundle signal is aborted. + - `lib/server-smoke.test.ts`: rename the landing test to `serves the landing page with header chrome and folded group rows, and no chart islands`. Replace the `group-toolbar`, `scope-slider` and `range-strip` expectations with: + +```ts +expect(html).toContain('class="group-body"'); +expect(html).toContain('class="group-headline"'); +expect(html).toContain('faster than Parquet'); // the fixture's compression group +expect(html).not.toContain('class="chart-card"'); +expect(html).not.toContain('data-role="group-toolbar"'); +``` + +- [ ] **Step 8: Verify.** + - Run `bun run --cwd bench/web build`, then the whole suite with `BENCH_TEST_DATABASE_URL` set. Everything passes, nothing is skipped, and `server-smoke` ran. + - `lint`, `format:check` and `bunx tsc --noEmit -p bench/web` are clean. +- [ ] **Step 9: Commit:** `Mount a bench group's toolbar and charts only while it is open`. + +### Task 3: Keep the open groups in the URL fragment + +**Files:** +- Create: `bench/web/lib/open-groups.ts`, `lib/open-groups.test.ts`, `components/OpenGroupsScript.tsx`, `components/OpenGroupsScript.test.tsx`, `components/LandingState.tsx`, `components/LandingState.test.tsx` +- Modify: `app/page.tsx`, `components/GroupSection.tsx` (`suppressHydrationWarning`), `components/Header.tsx`, `components/GroupNav.tsx` and its test, `components/GroupPermalink.tsx` and its test, `components/FilterBar.test.tsx`, `lib/server-smoke.test.ts` + +**Interfaces:** +- Consumes: Task 2's `groupBodiesReady`, `noteGroupBodyOpen` and `resetGroupBodies`; `groupAnchors` from `lib/anchor.ts`. +- Produces in `lib/open-groups.ts`. No helper may use `instanceof`, because the script test reads a second JSDOM realm. + - `export interface GroupRef { anchor: string; slug: string }` + - `export function parseOpenFragment(hash: string): string[]`. It splits on `,` before decoding, decodes each token, drops tokens where `decodeURIComponent` throws and empty tokens, and dedupes keeping first-seen order. + - `export function formatOpenFragment(anchors: readonly string[]): string`. It returns `''` for none, otherwise `'#' + anchors.join(',')`. Anchors are `[a-z0-9-]` by construction, so they are written as they are. + - `export function resolveOpenAnchors(tokens: readonly string[], groups: readonly GroupRef[]): string[]`. A token naming an anchor, or a legacy opaque slug, maps to that group's anchor. Unknown tokens drop. Fragment order is kept, without duplicates. + - `export function pageGroups(doc: Document): GroupRef[]`. These are the `section[data-group-slug][id]` elements in document order. + - `export function openAnchors(doc: Document): string[]` and `export function openGroupSlugs(doc: Document): string[]`, for the open groups in page order. + - `export function openGroupsFromHash(doc: Document, hash: string): string[]`. It opens the resolved groups and returns their anchors in fragment order. + - `export function openAndScrollTo(section: HTMLElement, doc: Document): void`. This moves out of GroupNav's `expandAndScroll`: smooth scrolling unless reduced motion is set, with `block: 'start'`. + - `export function scrollToGroup(doc: Document, anchor: string): boolean` + - `export function syncOpenFragment(win: Window): boolean`. + - The target URL is `pathname + search + formatOpenFragment(openAnchors(doc))`. + - It calls `history.replaceState(null, '', target)` only when that differs from the current URL. + - It is a no-op on a page without group sections. + - It catches a throwing `replaceState`. + - It returns whether it wrote. + - `export function setAllGroupsOpen(win: Window, open: boolean): void`. It sets `.open` on every `details.group-disclosure`, then calls `syncOpenFragment` once. +- `components/OpenGroupsScript.tsx`: `export const OPEN_GROUPS_BOOTSTRAP: string` and the server component `OpenGroupsScript()`. The component renders ``, + ); + }); + + it('is ES5 wrapped in a try, so no browser fails to parse it and no error escapes', () => { + expect(OPEN_GROUPS_BOOTSTRAP).toMatch(/^\(function\(\)\{try\{.*\}catch\(e\)\{\}\}\)\(\);$/); + expect(OPEN_GROUPS_BOOTSTRAP).not.toMatch(/=>|`|\blet\b|\bconst\b|\bclass\b/); + }); + + it('opens the listed groups while the page parses', () => { + const { doc, errors } = load('#clickbench,tpc-h-nvme-sf-1'); + expect(pageGroups(doc)).toEqual([ + { anchor: 'tpc-h-nvme-sf-1', slug: 'qm.tpch' }, + { anchor: 'random-access', slug: 'random_access.eyJ' }, + { anchor: 'clickbench', slug: 'qm.click' }, + ]); + expect(openAnchors(doc)).toEqual(['tpc-h-nvme-sf-1', 'clickbench']); + expect(errors).toEqual([]); + }); + + it.each([ + '', + '#', + '#random-access', + '#clickbench,tpc-h-nvme-sf-1', + '#random-access,random-access', + `#${encodeURIComponent('random_access.eyJ')}`, + '#unknown', + '#group-nav-panel', + '#%E0%A4%A', + '#%E0%A4%A,clickbench', + '#,,clickbench,', + ])('opens, while the page parses, exactly the groups the helpers resolve for "%s"', (hash) => { + const { doc, errors } = load(hash); + const listed = resolveOpenAnchors(parseOpenFragment(hash), pageGroups(doc)); + expect(openAnchors(doc)).toEqual( + pageGroups(doc) + .map((g) => g.anchor) + .filter((a) => listed.includes(a)), + ); + expect(errors).toEqual([]); + }); +}); diff --git a/bench/web/components/OpenGroupsScript.tsx b/bench/web/components/OpenGroupsScript.tsx new file mode 100644 index 0000000..98c7268 --- /dev/null +++ b/bench/web/components/OpenGroupsScript.tsx @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +/** + * The pre-paint bootstrap for the landing page's open groups: it opens the + * groups the URL fragment lists before the browser first paints them, so a + * reload, Back from a chart page or a shared link shows those groups open at + * once, with no flash of folded rows, and the browser's scroll restoration + * lands against the full page. + * + * It is a constant ES5 string with nothing interpolated into it. Its only + * input is `location.hash`. It splits the fragment on `,`, decodes each token + * in its own `try` (a malformed token is skipped), and compares each token by + * string equality with each group section's `id` (the readable anchor), then, + * for a token no anchor matched, with its `data-group-slug` (the opaque slug + * the first permalinks carried). It never builds a selector or markup from the + * fragment. For a match it sets `open` on that section's + * `details.group-disclosure`. This is exactly what `openGroupsFromHash` in + * `lib/open-groups.ts` does after hydration; `OpenGroupsScript.test.tsx` pins + * the two together. The outer `try` keeps any failure from reaching the page: + * without it, the groups simply open after hydration instead. + * + * Kept as one line so the shipped bytes are what this file shows. Readable: + * + * var t = location.hash.slice(1).split(','), + * s = document.querySelectorAll('section[data-group-slug][id]'), i, j, k, m, d; + * for (i = 0; i < t.length; i++) { + * try { k = decodeURIComponent(t[i]); } catch (e) { continue; } + * if (!k) continue; + * m = null; + * for (j = 0; j < s.length && !m; j++) if (s[j].id === k) m = s[j]; + * for (j = 0; j < s.length && !m; j++) if (s[j].getAttribute('data-group-slug') === k) m = s[j]; + * d = m && m.querySelector('details.group-disclosure'); + * if (d) d.open = true; + * } + */ +export const OPEN_GROUPS_BOOTSTRAP = + '(function(){try{' + + 'var t=location.hash.slice(1).split(","),' + + 's=document.querySelectorAll("section[data-group-slug][id]"),i,j,k,m,d;' + + 'for(i=0;i`, so it runs + * as soon as the groups' markup has parsed, in whatever streamed chunk + * carries them, and before they paint. The script adds `open` to a + * server-rendered `
`, which is why `GroupSection`'s disclosure + * carries `suppressHydrationWarning`. + * + * React never runs a script it renders on the client, so a client navigation + * to `/` skips this; `LandingState` opens the groups from the fragment then. + */ +export function OpenGroupsScript() { + return ( +