diff --git a/web/app/globals.css b/web/app/globals.css
index 96f8a77..6983dcd 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -493,6 +493,50 @@ main {
line-height: 1;
text-transform: uppercase;
}
+/*
+ * The copy-link button next to a group's chart count (GroupPermalink). A bare
+ * icon button with the info icon's muted-to-foreground hover treatment; the
+ * `--copied` state swaps in an accent-colored checkmark as confirmation.
+ */
+.group-permalink {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition:
+ color 0.15s ease,
+ background-color 0.15s ease;
+}
+.group-permalink:hover,
+.group-permalink:focus-visible {
+ color: var(--fg);
+ background: var(--border);
+}
+.group-permalink--copied,
+.group-permalink--copied:hover,
+.group-permalink--copied:focus-visible {
+ color: var(--accent);
+}
+/* Screen-reader-only text (the permalink button's copied announcement). */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
/*
* The small ⓘ next to a group title that surfaces the editorial blurb on
* hover and on focus. The icon is keyboard-focusable. In the JS-free server
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 01c6da7..86c7523 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -5,6 +5,7 @@ import { Footer } from '@/components/Footer';
import { GroupNav } from '@/components/GroupNav';
import { GroupSection } from '@/components/GroupSection';
import { Header } from '@/components/Header';
+import { groupAnchors } from '@/lib/anchor';
import { parseFilterCsv, singleSearchParam } from '@/lib/chart-format';
import { cachedFilterUniverse, cachedGroups } from '@/lib/data-cache';
@@ -42,23 +43,33 @@ export default async function Home({
const initialFormats = parseFilterCsv(singleSearchParam(params.format));
const [groups, universe] = await Promise.all([cachedGroups(), cachedFilterUniverse()]);
+ // Human-readable permalink anchors, one per section in render order (see
+ // lib/anchor.ts for why these are not the opaque API slugs).
+ const anchors = groupAnchors(groups.map((group) => group.name));
let nextIndex = 0;
return (
<>
- ({ name: group.name, slug: group.slug }))} />
+ ({
+ name: group.name,
+ slug: group.slug,
+ anchor: anchors[i],
+ }))}
+ />
{groups.length === 0 ? (
No data ingested yet.
) : (
<>
- {groups.map((group) => {
+ {groups.map((group, i) => {
const startIndex = nextIndex;
nextIndex += group.charts.length;
return (
diff --git a/web/components/GroupNav.test.tsx b/web/components/GroupNav.test.tsx
index dfb0791..142d2a7 100644
--- a/web/components/GroupNav.test.tsx
+++ b/web/components/GroupNav.test.tsx
@@ -3,14 +3,21 @@
// @vitest-environment jsdom
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { GroupNav, jumpToGroup, type GroupNavItem } from '@/components/GroupNav';
+import {
+ GroupNav,
+ jumpToGroup,
+ jumpToLocationHash,
+ type GroupNavItem,
+} from '@/components/GroupNav';
const GROUPS: GroupNavItem[] = [
- { name: 'Random Access', slug: 'random_access.abc' },
- { name: 'PolarSignals Profiling', slug: 'qmg.polar' },
+ { name: 'Random Access', slug: 'random_access.abc', anchor: 'random-access' },
+ { name: 'PolarSignals Profiling', slug: 'qmg.polar', anchor: 'polarsignals-profiling' },
];
describe('GroupNav markup', () => {
@@ -38,10 +45,11 @@ describe('jumpToGroup', () => {
function seedSections(): void {
document.body.innerHTML = `
-
+
+
-
+ `;
}
@@ -74,4 +82,129 @@ describe('jumpToGroup', () => {
expect(jumpToGroup('does-not-exist', document)).toBe(false);
expect(scrollSpy).not.toHaveBeenCalled();
});
+
+ describe('jumpToLocationHash', () => {
+ afterEach(() => {
+ window.history.replaceState(null, '', '/');
+ });
+
+ it('expands and scrolls to the group whose anchor id the fragment names', () => {
+ seedSections();
+ const scrollSpy = vi.fn();
+ Element.prototype.scrollIntoView = scrollSpy;
+ window.history.replaceState(null, '', '#random-access');
+
+ expect(jumpToLocationHash(document)).toBe(true);
+ const disclosure = document
+ .querySelector('#random-access')
+ ?.querySelector('details.group-disclosure');
+ expect(disclosure?.open).toBe(true);
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to the opaque-slug match for legacy fragments, decoding them', () => {
+ seedSections();
+ const scrollSpy = vi.fn();
+ Element.prototype.scrollIntoView = scrollSpy;
+ // Links copied before anchors became readable carried the API slug,
+ // percent-encoded by GroupPermalink; the jump decodes symmetrically.
+ window.history.replaceState(null, '', `#${encodeURIComponent('random_access.abc')}`);
+
+ expect(jumpToLocationHash(document)).toBe(true);
+ const disclosure = document
+ .querySelector('[data-group-slug="random_access.abc"]')
+ ?.querySelector('details.group-disclosure');
+ expect(disclosure?.open).toBe(true);
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('is a no-op for an empty or unknown fragment', () => {
+ seedSections();
+ const scrollSpy = vi.fn();
+ Element.prototype.scrollIntoView = scrollSpy;
+
+ expect(jumpToLocationHash(document)).toBe(false);
+ window.history.replaceState(null, '', '#does-not-exist');
+ expect(jumpToLocationHash(document)).toBe(false);
+ expect(scrollSpy).not.toHaveBeenCalled();
+ });
+
+ it('ignores fragments naming a non-group element id', () => {
+ seedSections();
+ const scrollSpy = vi.fn();
+ Element.prototype.scrollIntoView = scrollSpy;
+ window.history.replaceState(null, '', '#group-nav-panel');
+
+ expect(jumpToLocationHash(document)).toBe(false);
+ expect(scrollSpy).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('GroupNav jump-menu clicks', () => {
+ let container: HTMLElement;
+ let root: Root | null = null;
+
+ afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ container.remove();
+ document.body.innerHTML = '';
+ window.history.replaceState(null, '', '/');
+ });
+
+ it('expands the group, mirrors its anchor into the URL, and closes the panel', () => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ document.body.innerHTML = `
+ `;
+ const scrollSpy = vi.fn();
+ Element.prototype.scrollIntoView = scrollSpy;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ root?.render();
+ });
+
+ const toggle = container.querySelector('.group-nav-toggle');
+ act(() => {
+ toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ });
+ expect(container.querySelector('.group-nav-panel--open')).not.toBeNull();
+
+ const link = Array.from(container.querySelectorAll('.group-nav-link')).find(
+ (b) => b.textContent === 'Random Access',
+ );
+ act(() => {
+ link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ });
+
+ expect(window.location.hash).toBe('#random-access');
+ const disclosure = document
+ .querySelector('#random-access')
+ ?.querySelector('details.group-disclosure');
+ expect(disclosure?.open).toBe(true);
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ expect(container.querySelector('.group-nav-panel--open')).toBeNull();
+ });
+
+ it('does not touch the URL when the jump target is missing', () => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ // No sections seeded: every jump misses.
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ root?.render();
+ });
+
+ const link = container.querySelector('.group-nav-link');
+ act(() => {
+ link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ });
+
+ expect(window.location.hash).toBe('');
+ });
});
diff --git a/web/components/GroupNav.tsx b/web/components/GroupNav.tsx
index af48b1e..88dc7ed 100644
--- a/web/components/GroupNav.tsx
+++ b/web/components/GroupNav.tsx
@@ -6,12 +6,15 @@
import { useEffect, useRef, useState } from 'react';
/**
- * A group entry for the jump menu: its display name and the `data-group-slug`
- * carried by its landing-page `` (see [`GroupSection`]).
+ * A group entry for the jump menu: its display name, the `data-group-slug`
+ * carried by its landing-page `` (see [`GroupSection`]), and the
+ * readable permalink anchor (`lib/anchor.ts`) that section carries as its
+ * `id` — mirrored into the address bar when the entry is clicked.
*/
export interface GroupNavItem {
name: string;
slug: string;
+ anchor: string;
}
/**
@@ -33,19 +36,61 @@ export function jumpToGroup(slug: string, doc: Document = document): boolean {
if (section === undefined) {
return false;
}
+ expandAndScroll(section, doc);
+ return true;
+}
+
+/** Open a group section's disclosure and scroll it into view (the shared tail
+ * of [`jumpToGroup`] and [`jumpToLocationHash`]). */
+function expandAndScroll(section: HTMLElement, doc: Document): void {
const disclosure = section.querySelector('details.group-disclosure');
if (disclosure !== null) {
disclosure.open = true;
}
const reduceMotion = doc.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
section.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
- return true;
+}
+
+/**
+ * Expand and scroll to the group named by the window's URL fragment, if any.
+ *
+ * This is the landing half of the group-permalink flow: [`GroupPermalink`]
+ * copies `/#` (percent-encoded, decoded symmetrically here), and this
+ * turns that fragment back into an opened, scrolled-to group — the browser's
+ * native anchor scroll cannot open the `` disclosure on its own.
+ *
+ * The fragment resolves against the section `id` (the readable anchor from
+ * `lib/anchor.ts`), guarded to group sections so a stray fragment naming some
+ * other element's id (e.g. `#group-nav-panel`) is not treated as a group.
+ * Fragments carrying the opaque API slug — links copied before anchors became
+ * readable — fall back to the [`jumpToGroup`] slug match. An unknown or absent
+ * fragment is a no-op.
+ *
+ * Exported so the fragment handling is unit-testable without rendering.
+ */
+export function jumpToLocationHash(doc: Document = document): boolean {
+ const hash = doc.defaultView?.location.hash ?? '';
+ if (!hash.startsWith('#') || hash.length === 1) {
+ return false;
+ }
+ const fragment = decodeURIComponent(hash.slice(1));
+ const section = doc.getElementById(fragment);
+ if (section !== null && section.dataset.groupSlug !== undefined) {
+ expandAndScroll(section, doc);
+ return true;
+ }
+ return jumpToGroup(fragment, doc);
}
/**
* A left-side "Jump to group" menu: a fixed toggle button that opens a panel
* listing every group, each a button that expands and scrolls to that group's
- * section (via [`jumpToGroup`]) and then closes the panel.
+ * section (via [`jumpToGroup`]), mirrors the group's permalink anchor into
+ * the address bar, and then closes the panel.
+ *
+ * Also hosts the hash-jump effect: on mount and on every later `hashchange`,
+ * [`jumpToLocationHash`] expands and scrolls to the group a `/#`
+ * permalink points at.
*
* Toggle-driven (not hover) so it works on touch and keyboard, mirroring the
* header's hamburger nav ([`Header`]): `aria-expanded` / `aria-controls` on the
@@ -57,6 +102,18 @@ export function GroupNav({ groups }: { groups: GroupNavItem[] }) {
const panelRef = useRef(null);
const toggleRef = useRef(null);
+ // Land group permalinks: expand and scroll to the fragment's group on mount
+ // (the copied-link entry path) and on later `hashchange` (back/forward
+ // between fragments, or an in-page anchor click).
+ useEffect(() => {
+ jumpToLocationHash();
+ const onHashChange = (): void => {
+ jumpToLocationHash();
+ };
+ window.addEventListener('hashchange', onHashChange);
+ return () => window.removeEventListener('hashchange', onHashChange);
+ }, []);
+
// Close on outside click and Escape while open (the header nav's pattern).
useEffect(() => {
if (!open) {
@@ -115,7 +172,14 @@ export function GroupNav({ groups }: { groups: GroupNavItem[] }) {
className="group-nav-link"
type="button"
onClick={() => {
- jumpToGroup(group.slug);
+ // Mirror the group's anchor into the address bar so a jump
+ // leaves a shareable URL, exactly like the header copy-link
+ // button. replaceState (not `location.hash =`) avoids firing
+ // `hashchange`, which would re-trigger the hash-jump effect
+ // and double-scroll.
+ if (jumpToGroup(group.slug)) {
+ window.history.replaceState(null, '', `#${encodeURIComponent(group.anchor)}`);
+ }
setOpen(false);
}}
>
diff --git a/web/components/GroupPermalink.test.tsx b/web/components/GroupPermalink.test.tsx
new file mode 100644
index 0000000..627caeb
--- /dev/null
+++ b/web/components/GroupPermalink.test.tsx
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright the Vortex contributors
+
+// @vitest-environment jsdom
+
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { GroupPermalink, groupPermalinkUrl } from '@/components/GroupPermalink';
+
+describe('groupPermalinkUrl', () => {
+ it('joins origin, pathname, and the readable anchor fragment', () => {
+ const loc = { origin: 'https://bench.vortex.dev', pathname: '/' };
+ expect(groupPermalinkUrl('random-access', loc)).toBe('https://bench.vortex.dev/#random-access');
+ });
+
+ it('percent-encodes fragment-hostile anchor characters defensively', () => {
+ const loc = { origin: 'https://bench.vortex.dev', pathname: '/' };
+ expect(groupPermalinkUrl('a#b c', loc)).toBe('https://bench.vortex.dev/#a%23b%20c');
+ });
+});
+
+describe('GroupPermalink markup', () => {
+ it('renders a labelled icon button', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('class="group-permalink"');
+ expect(html).toContain('type="button"');
+ expect(html).toContain('aria-label="Copy link to Random Access"');
+ // The resting state carries no copied styling.
+ expect(html).not.toContain('group-permalink--copied');
+ });
+});
+
+describe('GroupPermalink click behavior', () => {
+ let container: HTMLElement;
+ let root: Root | null = null;
+ let copiedText: string[];
+
+ beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ vi.useFakeTimers();
+ copiedText = [];
+ // jsdom has no Clipboard API; install a capturing stub.
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: (text: string) => {
+ copiedText.push(text);
+ return Promise.resolve();
+ },
+ },
+ configurable: true,
+ });
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ container.remove();
+ vi.useRealTimers();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (navigator as any).clipboard;
+ window.history.replaceState(null, '', '/');
+ });
+
+ /** Mount the button inside a real ``/`` host, as on the
+ * landing page, and return the pieces the assertions need. */
+ function mount(): { button: HTMLButtonElement; details: HTMLDetailsElement } {
+ const details = document.createElement('details');
+ const summary = document.createElement('summary');
+ details.appendChild(summary);
+ container.appendChild(details);
+ root = createRoot(summary);
+ act(() => {
+ root?.render();
+ });
+ const button = container.querySelector('button.group-permalink');
+ if (!button) {
+ throw new Error('permalink button did not render');
+ }
+ return { button, details };
+ }
+
+ it('copies the permalink, mirrors the fragment, and never toggles the disclosure', () => {
+ const { button, details } = mount();
+
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true });
+ act(() => {
+ button.dispatchEvent(click);
+ });
+
+ expect(copiedText).toEqual([`${window.location.origin}/#random-access`]);
+ expect(window.location.hash).toBe('#random-access');
+ // preventDefault suppresses the activation that would otherwise
+ // toggle the group open.
+ expect(click.defaultPrevented).toBe(true);
+ expect(details.open).toBe(false);
+ });
+
+ it('shows the copied state, then reverts after the feedback window', () => {
+ const { button } = mount();
+
+ act(() => {
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ });
+ expect(button.className).toContain('group-permalink--copied');
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(button.className).not.toContain('group-permalink--copied');
+ });
+});
diff --git a/web/components/GroupPermalink.tsx b/web/components/GroupPermalink.tsx
new file mode 100644
index 0000000..c271641
--- /dev/null
+++ b/web/components/GroupPermalink.tsx
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright the Vortex contributors
+
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+
+/** How long the copied-confirmation state stays visible, in milliseconds. */
+const COPIED_FEEDBACK_MS = 1500;
+
+/**
+ * Build the shareable URL for a group: the landing page with the group's
+ * human-readable anchor (see `lib/anchor.ts`) as the fragment.
+ * [`GroupSection`] stamps the anchor as the section's `id`, so the fragment is
+ * a real anchor even without JavaScript; with JavaScript, [`GroupNav`]'s
+ * hash-jump effect also expands the group's disclosure on load.
+ *
+ * Anchors are already fragment-safe (`[a-z0-9-]`), but the fragment is
+ * percent-encoded anyway so a future anchor shape cannot silently produce an
+ * invalid URL; the hash-jump effect decodes symmetrically.
+ *
+ * Exported so the URL shape is unit-testable without a clipboard.
+ */
+export function groupPermalinkUrl(
+ anchor: string,
+ loc: Pick,
+): string {
+ return `${loc.origin}${loc.pathname}#${encodeURIComponent(anchor)}`;
+}
+
+/**
+ * The copy-link button in a group's summary header. Clicking it copies the
+ * group's permalink (see [`groupPermalinkUrl`]) to the clipboard, mirrors the
+ * fragment into the address bar via `history.replaceState` (so the URL is
+ * shareable even where the Clipboard API is unavailable, e.g. plain-HTTP dev
+ * hosts), and swaps the link glyph for a checkmark for a moment as
+ * confirmation.
+ *
+ * The button lives inside the disclosure's ``, where any unhandled
+ * click toggles the group open/closed; `preventDefault` suppresses that
+ * native toggle so copying a link never collapses the group.
+ */
+export function GroupPermalink({ anchor, groupName }: { anchor: string; groupName: string }) {
+ const [copied, setCopied] = useState(false);
+ const timerRef = useRef | null>(null);
+
+ // Cancel a pending feedback reset on unmount so it never fires after the
+ // island is gone.
+ useEffect(
+ () => () => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ }
+ },
+ [],
+ );
+
+ return (
+
+ );
+}
+
+/** A chain-link glyph, matching the header icons' stroke style. */
+function LinkIcon() {
+ return (
+
+ );
+}
+
+/** The copied-confirmation checkmark. */
+function CheckIcon() {
+ return (
+
+ );
+}
diff --git a/web/components/GroupSection.test.tsx b/web/components/GroupSection.test.tsx
index 5da563e..992b955 100644
--- a/web/components/GroupSection.test.tsx
+++ b/web/components/GroupSection.test.tsx
@@ -32,11 +32,19 @@ const RANDOM_ACCESS: Group = {
describe('GroupSection', () => {
it('renders the group-details shell with a collapsed disclosure', () => {
const html = renderToStaticMarkup(
- ,
+ ,
);
expect(html).toContain('class="group-details"');
expect(html).toContain('data-group-name="Random Access"');
expect(html).toContain(`data-group-slug="${RANDOM_ACCESS.slug}"`);
+ // The readable anchor is the section's id, the target of group
+ // permalinks (`/#random-access`) - NOT the opaque API slug.
+ expect(html).toContain('id="random-access"');
// The disclosure is collapsed by default (no `open` attribute on the tag).
expect(html).toContain('');
expect(html).toContain('Random Access');
@@ -44,16 +52,39 @@ describe('GroupSection', () => {
it('renders the description info-icon and a pluralized chart count', () => {
const html = renderToStaticMarkup(
- ,
+ ,
);
expect(html).toContain('class="group-info-icon"');
expect(html).toContain('data-tooltip="Tests selecting arbitrary row indices on NVMe"');
expect(html).toContain('2 charts');
});
+ it('renders the copy-link button in the summary header', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('class="group-permalink"');
+ expect(html).toContain('aria-label="Copy link to Random Access"');
+ });
+
it('renders the summary card inside the section', () => {
const html = renderToStaticMarkup(
- ,
+ ,
);
expect(html).toContain('class="benchmark-scores-summary"');
expect(html).toContain('Random Access Performance');
@@ -61,7 +92,12 @@ describe('GroupSection', () => {
it('renders one chart-card shell per chart with page-wide indices and permalinks', () => {
const html = renderToStaticMarkup(
- ,
+ ,
);
expect(html).toContain('class="chart-grid"');
// First chart takes startIndex, second takes startIndex + 1.
@@ -81,7 +117,7 @@ describe('GroupSection', () => {
charts: [{ name: 'recall', slug: 'vs.eyJhIjoxfQ' }],
};
const html = renderToStaticMarkup(
- ,
+ ,
);
expect(html).toContain('1 chart');
expect(html).not.toContain('class="group-info-icon"');
diff --git a/web/components/GroupSection.tsx b/web/components/GroupSection.tsx
index cdd79b9..fa1d10f 100644
--- a/web/components/GroupSection.tsx
+++ b/web/components/GroupSection.tsx
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: Copyright the Vortex contributors
import { Chart } from '@/components/Chart';
+import { GroupPermalink } from '@/components/GroupPermalink';
import { GroupToolbar } from '@/components/GroupToolbar';
import { SummaryCard } from '@/components/SummaryCard';
import type { FilterUniverse } from '@/lib/chart-format';
@@ -29,19 +30,33 @@ import type { Group } from '@/lib/queries';
*
* `startIndex` is the running page-wide chart index of this group's first
* chart, so `data-chart-index` is unique across every chart on the page.
+ *
+ * The section carries `id={anchor}` — the human-readable anchor the page
+ * derives from the group name via `lib/anchor.ts` (`#tpc-h-nvme-sf-1`, not the
+ * opaque API slug) — so each group has a stable, legible URL fragment: the
+ * summary row's [`GroupPermalink`] button copies `/#`, the fragment
+ * scrolls natively even without JavaScript, and [`GroupNav`]'s hash-jump
+ * effect expands the disclosure on load.
*/
export function GroupSection({
group,
+ anchor,
startIndex,
universe,
}: {
group: Group;
+ anchor: string;
startIndex: number;
universe: FilterUniverse;
}) {
const chartCount = group.charts.length;
return (
-
+
@@ -60,6 +75,7 @@ export function GroupSection({
{chartCount} chart{chartCount !== 1 ? 's' : ''}
+
diff --git a/web/lib/anchor.test.ts b/web/lib/anchor.test.ts
new file mode 100644
index 0000000..d81b7e0
--- /dev/null
+++ b/web/lib/anchor.test.ts
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright the Vortex contributors
+
+import { describe, expect, it } from 'vitest';
+
+import { groupAnchor, groupAnchors } from '@/lib/anchor';
+
+describe('groupAnchor', () => {
+ it('slugifies real group display names into readable anchors', () => {
+ expect(groupAnchor('Compression')).toBe('compression');
+ expect(groupAnchor('Random Access')).toBe('random-access');
+ expect(groupAnchor('TPC-H (NVMe) (SF=1)')).toBe('tpc-h-nvme-sf-1');
+ expect(groupAnchor('TPC-DS (NVMe) (SF=1)')).toBe('tpc-ds-nvme-sf-1');
+ expect(groupAnchor('Statistical and Population Genetics')).toBe(
+ 'statistical-and-population-genetics',
+ );
+ expect(groupAnchor('fineweb [nvme]')).toBe('fineweb-nvme');
+ expect(groupAnchor('fineweb [s3]')).toBe('fineweb-s3');
+ });
+
+ it('collapses punctuation runs and trims edge dashes', () => {
+ expect(groupAnchor(' A -- weird // name! ')).toBe('a-weird-name');
+ });
+
+ it('never returns an empty anchor', () => {
+ expect(groupAnchor('***')).toBe('group');
+ });
+});
+
+describe('groupAnchors', () => {
+ it('anchors names in order and de-duplicates collisions with suffixes', () => {
+ expect(groupAnchors(['Random Access', 'random access', 'Random-Access!'])).toEqual([
+ 'random-access',
+ 'random-access-2',
+ 'random-access-3',
+ ]);
+ });
+
+ it('keeps the full production group list collision-free', () => {
+ const names = [
+ 'Compression',
+ 'Compression Size',
+ 'Clickbench',
+ 'TPC-H (NVMe) (SF=1)',
+ 'TPC-H (S3) (SF=1)',
+ 'TPC-H (NVMe) (SF=10)',
+ 'TPC-H (S3) (SF=10)',
+ 'TPC-H (NVMe) (SF=100)',
+ 'TPC-H (S3) (SF=100)',
+ 'TPC-DS (NVMe) (SF=1)',
+ 'Random Access',
+ 'Statistical and Population Genetics',
+ 'PolarSignals Profiling',
+ 'fineweb [nvme]',
+ 'fineweb [s3]',
+ 'appian [nvme]',
+ ];
+ expect(groupAnchors(names)).toEqual([
+ 'compression',
+ 'compression-size',
+ 'clickbench',
+ 'tpc-h-nvme-sf-1',
+ 'tpc-h-s3-sf-1',
+ 'tpc-h-nvme-sf-10',
+ 'tpc-h-s3-sf-10',
+ 'tpc-h-nvme-sf-100',
+ 'tpc-h-s3-sf-100',
+ 'tpc-ds-nvme-sf-1',
+ 'random-access',
+ 'statistical-and-population-genetics',
+ 'polarsignals-profiling',
+ 'fineweb-nvme',
+ 'fineweb-s3',
+ 'appian-nvme',
+ ]);
+ });
+});
diff --git a/web/lib/anchor.ts b/web/lib/anchor.ts
new file mode 100644
index 0000000..15befe9
--- /dev/null
+++ b/web/lib/anchor.ts
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright the Vortex contributors
+
+/**
+ * Human-readable URL anchors for the landing page's group sections.
+ *
+ * Group permalinks (`/#tpc-h-nvme-sf-1`) use these anchors, NOT the opaque
+ * API slugs from [`./slug`]: a fragment only has to match a section `id` on
+ * the page, so it never needs the slug's machine-decodable base64 payload,
+ * and the display name — stable and unique per page — makes a far friendlier
+ * URL. The API keeps the opaque slug.
+ */
+
+/**
+ * Derive a URL anchor from a group display name: lowercase, every run of
+ * non-alphanumerics collapsed to a single `-`, leading/trailing dashes
+ * trimmed. `TPC-H (NVMe) (SF=1)` becomes `tpc-h-nvme-sf-1`. A name with no
+ * alphanumerics at all falls back to `group` so the anchor is never empty.
+ */
+export function groupAnchor(name: string): string {
+ const anchor = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ return anchor === '' ? 'group' : anchor;
+}
+
+/**
+ * Anchor every group name on a page, de-duplicating collisions with `-2`,
+ * `-3`, … suffixes in encounter order. Names are unique today, but two names
+ * differing only in punctuation would slugify identically; suffixing keeps
+ * every section `id` (and thus every permalink) unambiguous rather than
+ * silently landing on the first collision.
+ */
+export function groupAnchors(names: readonly string[]): string[] {
+ const seen = new Set();
+ return names.map((name) => {
+ const base = groupAnchor(name);
+ let anchor = base;
+ for (let i = 2; seen.has(anchor); i++) {
+ anchor = `${base}-${i}`;
+ }
+ seen.add(anchor);
+ return anchor;
+ });
+}