diff --git a/README.md b/README.md
index debd40c..2878a5e 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ Either way you get an `valcore` command on your `PATH`.
## Web UI
-`valcore serve` opens a dark-themed web UI with four surfaces:
+`valcore serve` opens a dark-themed web UI with five surfaces:
- **Overview** — the landing page, summarizing what you have and pointing to the next
step.
@@ -65,6 +65,12 @@ Either way you get an `valcore` command on your `PATH`.
from a description.
- **Runs** — inspect completed runs, their metrics, and per-row scores, and compare runs
against each other.
+- **Docs** — how the product works, in four tabs: Evals, Datasets, Runs, and CLI.
+
+The docs surface explains the concepts the other four assume — label spaces, frozen
+versions, run kinds, agreement metrics — so you can read them without leaving the app.
+It covers the workflow; this README stays the reference for install, credentials,
+portable packages, CI, and Logfire.
## Quickstart
@@ -80,6 +86,9 @@ valcore serve
and datasets in the UI, then drive runs from the command line. Both can be written by
hand or generated from a description; a generated result is an editable draft either way.
+From there, the **Docs** tab in the app walks through the full workflow — authoring a
+judge, getting labeled rows, and reading what a run measured.
+
## Seeding one from the other
An evaluator and a dataset have to agree on columns, so rather than retype that shape you
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
new file mode 100644
index 0000000..9f1b4b5
--- /dev/null
+++ b/web/src/App.test.tsx
@@ -0,0 +1,46 @@
+// Route-table coverage. DocsPage.test.tsx mounts the docs routes itself, which proves the
+// component but not that App declares them — this file is what fails if /docs is missing
+// from the real route table.
+//
+// Only docs paths are exercised: the other pages fetch on mount, and this suite is about
+// wiring, not about mocking every endpoint.
+import { afterEach, describe, expect, it } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import App from "./App";
+import { DOCS } from "./docs/registry";
+
+afterEach(() => {
+ cleanup();
+});
+
+function renderApp(path: string) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("App routes", () => {
+ it("serves the docs section at /docs", () => {
+ renderApp("/docs");
+
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe(DOCS[0].title);
+ });
+
+ it("serves a docs tab at /docs/:slug", () => {
+ renderApp("/docs/runs");
+
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Runs");
+ });
+
+ it("renders the docs section inside the app shell", () => {
+ renderApp("/docs");
+
+ // Two navs: the sidebar and the tab strip. If docs were declared outside the layout
+ // route, the sidebar would vanish while reading them.
+ expect(screen.getAllByRole("navigation")).toHaveLength(2);
+ expect(screen.getByRole("link", { name: "Docs" }).getAttribute("href")).toBe("/docs");
+ });
+});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index fd1e909..395ebee 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -4,6 +4,7 @@ import OverviewPage from "./pages/OverviewPage";
import EvaluatorsPage from "./pages/EvaluatorsPage";
import DatasetsPage from "./pages/DatasetsPage";
import RunsPage from "./pages/RunsPage";
+import DocsPage from "./pages/DocsPage";
// All routes are declared here up front. The route table is maintained alongside the
// pages, so adding or moving a route means editing this file.
@@ -19,6 +20,10 @@ export default function App() {
} />
} />
} />
+ {/* Both point at DocsPage: the bare path renders the first tab, so the section
+ always has content and a stale slug degrades instead of blanking. */}
+ } />
+ } />
);
diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx
index 55758ac..53cda27 100644
--- a/web/src/components/Layout.test.tsx
+++ b/web/src/components/Layout.test.tsx
@@ -33,14 +33,47 @@ describe("Layout nav", () => {
expect(screen.getByRole("link", { name: "Datasets" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Runs" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Compare" })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Docs" })).toBeTruthy();
});
- it("renders exactly the five expected nav links and nothing else", () => {
+ it("renders exactly the six expected nav links and nothing else", () => {
renderLayout("/");
// Guards against a stray link (e.g. the brand wordmark accidentally becoming
// a link, or a footer/version badge that this task must not add).
- expect(screen.getAllByRole("link")).toHaveLength(5);
+ expect(screen.getAllByRole("link")).toHaveLength(6);
+ });
+
+ it("orders Docs directly under Overview, above the labelled groups", () => {
+ renderLayout("/");
+
+ // Docs sits with Overview as the two ungrouped entries at the top: it is read
+ // before you have anything to author or measure, so it should not be buried under
+ // the working surfaces.
+ expect(screen.getAllByRole("link").map((link) => link.textContent)).toEqual([
+ "Overview",
+ "Docs",
+ "Evaluators",
+ "Datasets",
+ "Runs",
+ "Compare",
+ ]);
+ });
+
+ it("points Docs at /docs", () => {
+ renderLayout("/");
+
+ expect(screen.getByRole("link", { name: "Docs" }).getAttribute("href")).toBe("/docs");
+ });
+
+ it("keeps Docs active on a docs sub-route", () => {
+ renderLayout("/docs/datasets");
+
+ // Docs deliberately omits `end`: every /docs/:slug tab must keep the nav item
+ // lit, otherwise the sidebar goes blank-looking while reading any tab but the
+ // first.
+ expect(screen.getByRole("link", { name: "Docs" }).getAttribute("aria-current")).toBe("page");
+ expect(screen.getByRole("link", { name: "Overview" }).getAttribute("aria-current")).toBeNull();
});
it("points Overview at / and Compare at /runs/compare", () => {
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index 9529b09..1538f1a 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -3,6 +3,7 @@ import { NavLink, Outlet } from "react-router-dom";
import {
CompareIcon,
DatasetIcon,
+ DocsIcon,
EvaluatorIcon,
OverviewIcon,
RunIcon,
@@ -11,9 +12,12 @@ import {
type NavItem = { to: string; label: string; Icon: ComponentType<{ className?: string }> };
type NavSection = { label: string; items: NavItem[] };
-// Overview stands alone above the labelled groups; the two sections mirror the
-// author-then-measure flow of the product.
+// Overview and Docs are the two ungrouped entries at the top; the labelled sections
+// below them mirror the author-then-measure flow of the product. Docs sits up here
+// rather than at the bottom because it is what you read before you have anything to
+// author or measure — a new user needs it first, not last.
const OVERVIEW: NavItem = { to: "/", label: "Overview", Icon: OverviewIcon };
+const DOCS: NavItem = { to: "/docs", label: "Docs", Icon: DocsIcon };
const SECTIONS: NavSection[] = [
{
label: "Author",
@@ -56,6 +60,8 @@ export default function Layout() {
{/* `end` keeps the "/" link from matching every route and staying active. */}
+ {/* No `end`: every /docs/:slug tab keeps the nav item lit. */}
+
{SECTIONS.map((section) => (
{section.label}
diff --git a/web/src/components/icons.test.tsx b/web/src/components/icons.test.tsx
index de68d0c..dc4c867 100644
--- a/web/src/components/icons.test.tsx
+++ b/web/src/components/icons.test.tsx
@@ -4,6 +4,7 @@ import {
ChevronIcon,
CompareIcon,
DatasetIcon,
+ DocsIcon,
EvaluatorIcon,
InfoIcon,
OverviewIcon,
@@ -26,6 +27,7 @@ const ICONS = [
["InfoIcon", InfoIcon],
["ChevronIcon", ChevronIcon],
["PlusIcon", PlusIcon],
+ ["DocsIcon", DocsIcon],
] as const;
describe("icons", () => {
diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx
index 3f019ac..eda3250 100644
--- a/web/src/components/icons.tsx
+++ b/web/src/components/icons.tsx
@@ -107,3 +107,15 @@ export function PlusIcon(props: IconProps): JSX.Element {
);
}
+
+// An open book: two facing pages over a spine. Distinct from DatasetIcon's stacked
+// rules at 16px, which matters because both sit in the same nav column.
+export function DocsIcon(props: IconProps): JSX.Element {
+ return (
+
+ );
+}
diff --git a/web/src/docs/content/Cli.tsx b/web/src/docs/content/Cli.tsx
new file mode 100644
index 0000000..57cfe57
--- /dev/null
+++ b/web/src/docs/content/Cli.tsx
@@ -0,0 +1,90 @@
+// The CLI tab. Deliberately not a mirror of the README's command table: the README stays
+// canonical for the exhaustive listing (it is the PyPI long_description, where a reader
+// cannot reach these pages), and this tab teaches the usage patterns that table does not.
+
+import { CodeBlock, DocLink, DocNote, DocPage, DocSection } from "../primitives";
+
+export function Cli(): JSX.Element {
+ return (
+
+
+
+ The app and the CLI are two front ends over the same SQLite workspace. Authoring is
+ easier in the app; running, exporting, and anything scripted is easier from the command
+ line. Nothing has to be running for the CLI to work — it opens the database directly, so
+ runs do not depend on valcore serve.
+
+ valcore serve
+
+
+
+
+ Evaluators, versions, and datasets are addressable by name or by a unique id prefix, so
+ you rarely need a full id:
+
+ valcore run 7f3a my-dataset
+
+ An ambiguous value is an error that lists the candidates rather than picking one. Add
+ characters until it resolves.
+
+
+
+
+
+
+ --version — a version other than the active one
+
+
+ --kind validation — compare against labels instead of only recording
+ output
+
+
+ --concurrency — how many rows are in flight at once
+
+
+ --watch — one line per completed row, rather than a final summary
+
+
+ --json — machine-readable results on stdout
+
+
+ --min-accuracy — exit non-zero below a threshold, for CI
+
+
+
+
+
+
+ valcore config holds credentials and defaults — the gateway key, Logfire
+ tokens, the default model, and the path to the config file. Keys are only ever set from
+ the CLI; no secret crosses HTTP into the browser.
+
+ valcore config set-key
+
+ valcore list shows what the workspace holds, as a table or with{" "}
+ --json. valcore export and valcore import move
+ evaluators and datasets between machines. valcore logfire push sends a
+ dataset to Logfire's hosted store. valcore skills installs the bundled
+ agent skills so a coding agent can drive valcore for you.
+
+
+
+
+
+ Every command takes --db on the group, which is how you keep a scratch
+ workspace or a per-project database separate from the default under{" "}
+ ~/.valcore:
+
+ valcore --db ./evals.sqlite list evaluators
+
+ For the complete command table, plus install, CI recipes, portable packages, and
+ Logfire setup, see the README in the repository. This tab covers the patterns, not
+ every flag.
+
+
+ Back to Runs for what the run commands measure.
+
+
+
+ );
+}
diff --git a/web/src/docs/content/Datasets.tsx b/web/src/docs/content/Datasets.tsx
new file mode 100644
index 0000000..928432d
--- /dev/null
+++ b/web/src/docs/content/Datasets.tsx
@@ -0,0 +1,98 @@
+// The datasets tab: where rows come from, how labels get onto them, and why labels are
+// optional until you want to measure agreement.
+
+import { CodeBlock, DocLink, DocNote, DocPage, DocSection } from "../primitives";
+
+export function Datasets(): JSX.Element {
+ return (
+
+
+
+ A dataset is a table of rows with named columns, and it arrives one of three ways.
+ Upload a CSV when you already have data. Create a blank dataset and author rows by
+ hand when you are working from a handful of known cases. Generate one from a
+ description when you need coverage you do not have yet.
+
+
+ Generation is a starting point, not an answer: a generated dataset is an editable draft
+ like any other, and its rows are yours to correct.
+
+
+
+
+
+ A generated dataset keeps the request that produced it — the description, the columns
+ asked for, and the per-column notes — as read-only provenance. Months later that is how
+ you know what these rows were meant to represent. Uploaded and blank datasets have no
+ such record, which is normal rather than missing.
+
+
+ Prescribing a label mix is opt-in. Leave it off and the distribution follows whatever
+ the description asks for. Turn it on to pin the proportions — useful when you need a
+ rare category represented well enough to measure. The editor works in whole percents
+ and shows the apportioned row count beside each label, so the number the model is
+ actually told is never hidden behind a percentage.
+
+
+
+
+
+ Labels are ground truth: what the right answer is, independent of what any judge says.
+ The labeling grid is built for getting through rows quickly with the keyboard.
+
+
+
+ j / k — move between rows
+
+
+ 1–9 — apply a categorical label
+
+
+ a — accept the suggested label
+
+
+ u — clear the label
+
+
+ ? — show the full shortcut list
+
+
+
+ Every change saves immediately, applied optimistically and rolled back if the write
+ fails. Cells are editable in place, rows can be added or deleted, and per-column notes
+ record what a column is supposed to hold.
+
+
+
+
+
+ A dataset needs no labels to be scored. An ordinary run just records what the judge
+ said. Labels are only required for a validation run, which compares the judge against
+ them to measure agreement.
+
+
+ Validation is unavailable — and says so — while a dataset still has unlabeled rows. A
+ partially labeled dataset would produce an agreement number over a silently shrinking
+ subset.
+
+
+
+
+
+ A dataset that turned out too small can be extended in place: generating more rows
+ reuses the original request so the additions match the shape and intent of what is
+ already there.
+
+
+ Export writes a dataset out as a Python script or as a portable JSON package, and the
+ CLI reads it back:
+
+ Work with data in Datasets, or see{" "}
+ Evals for the judge side of the column contract.
+
+
+
+ );
+}
diff --git a/web/src/docs/content/Evals.tsx b/web/src/docs/content/Evals.tsx
new file mode 100644
index 0000000..0cb6ddd
--- /dev/null
+++ b/web/src/docs/content/Evals.tsx
@@ -0,0 +1,96 @@
+// The evaluators tab. Written for someone with the version editor open in another tab,
+// so it explains the concepts the form assumes rather than restating its field labels.
+
+import { CodeBlock, DocLink, DocNote, DocPage, DocSection } from "../primitives";
+
+export function Evals(): JSX.Element {
+ return (
+
+
+
+ An evaluator is an LLM-as-judge: a prompt, a model, and an output shape that together
+ score one thing about a row of data. It reads the columns you give it and returns a
+ label — a category from a fixed set, or a number — plus its reasoning.
+
+
+ An evaluator is not tied to a dataset. The same judge runs over any dataset that
+ supplies the columns it requires, which is what makes a judge comparable across data.
+
+
+
+
+
+ The output field defines what a judge returns. Categorical fields declare their label
+ space up front — the set of allowed values — and that space is what the labeling grid
+ offers and what agreement metrics are computed over. Numeric fields declare a range
+ instead, and are scored with error measures rather than a confusion matrix.
+
+
+ Declaring the label space is what lets valcore tell a disagreement from an invalid
+ answer. A judge that returns something outside the space is a failure, not a low score.
+
+
+
+
+
+ Every change to a judge lands as a version, and one version is active — the one used
+ when you do not name another. Editing an unsaved draft edits in place. Editing a frozen
+ version copies it first, so a version that has already produced runs is never mutated
+ underneath them.
+
+
+ That is the point of freezing: a run records which version produced it, so a score
+ stays attributable. When two versions exist you can read the exact difference between
+ them in the version diff rather than guessing from timestamps.
+
+
+ Validation runs every render and gates Save before the server sees the payload, so an
+ incomplete version tells you what is missing instead of failing on submit.
+
+
+
+
+
+ A judge can be granted capabilities when reading the row is not enough. FileSystem
+ gives it a rooted directory to read from; Shell gives it an allow-listed set of
+ commands and a timeout. Both are opt-in per version and configured alongside the
+ prompt.
+
+
+ Grant the narrowest thing that answers the question. A capability widens what a judge
+ can see, which also widens what can change between runs.
+
+
+
+
+
+ An evaluator and a dataset have to agree on columns, so rather than retype that shape,
+ generate one from the other. A dataset generated from a version always gets that
+ version's required columns; you can name extra columns, and per-column notes say
+ what each should contain. Suggested labels are optional — ask for them when you want
+ the model to propose ground truth, and the label space comes from the evaluator.
+
+
+ The reverse direction works too: generate an evaluator from a dataset and it is
+ drafted against that dataset's columns. The result is an editable draft, not a
+ saved version.
+
+
+ Author judges in Evaluators, then see{" "}
+ Datasets for what happens to the data side.
+
+
+
+
+
+ Runs are driven from the command line, against the active version unless you name
+ another:
+
+ valcore run my-evaluator my-dataset
+
+ See Runs for run kinds and what gets measured.
+
+
+
+ );
+}
diff --git a/web/src/docs/content/Keys.tsx b/web/src/docs/content/Keys.tsx
new file mode 100644
index 0000000..1a1d780
--- /dev/null
+++ b/web/src/docs/content/Keys.tsx
@@ -0,0 +1,137 @@
+// The keys tab: the three credentials the Overview setup card lists, where each one comes
+// from, and what stops working without it. Deliberately the first tab — nothing that calls
+// a model runs until the gateway key exists.
+//
+// Key names, commands, and required/optional status here must match
+// src/valcore/api/routes/setup.py, which is what the Overview card renders from.
+
+import { CodeBlock, DocLink, DocNote, DocPage, DocSection, ExternalLink } from "../primitives";
+
+export function Keys(): JSX.Element {
+ return (
+
+
+
+ The setup card on Overview lists every credential valcore
+ knows about and whether it is currently set. One is required; two are optional and
+ only matter if you use Logfire.
+
+
+
+ Pydantic AI Gateway key — required. Runs evaluators, and generates
+ evaluators and datasets.
+
+
+ Logfire write token — optional. Sends run traces to Logfire.
+
+
+ Logfire API key — optional. Pushes datasets to Logfire's hosted
+ store.
+
+
+
+ Keys are never entered through the web UI — no secret crosses HTTP — so every one of
+ them is set from the command line. The card reports presence only; it never shows a
+ key back to you.
+
+
+
+
+
+ valcore reaches models through the Pydantic AI Gateway, and that is currently the only
+ route — there is no direct-to-provider client and no per-provider key. Without it,
+ generation and runs are unavailable and the UI says why.
+
+
+ Everything that does not call a model still works with no key at all: authoring by
+ hand, uploading a CSV, editing rows, hand-labeling, and every export.
+
+ valcore config set-key
+
+ Run it with no argument to be prompted without the key echoing to your terminal or
+ landing in shell history.
+
+
+
+
+
+ Create the key in the Pydantic AI Gateway and paste it into the command above. The
+ gateway documentation covers account setup and where keys are issued:
+
+
+
+ ai.pydantic.dev/gateway
+
+
+
+ Model strings are always gateway/<provider>:<model> — for
+ example gateway/anthropic:claude-sonnet-5, which is the default. Valid
+ providers are anthropic, openai, google,{" "}
+ google-cloud, bedrock, and groq. A string that
+ does not match this shape is rejected before any request is made, so a bare model name
+ fails immediately with a clear error rather than at call time.
+
+
+
+
+
+ With a write token configured, each run opens a valcore.run span carrying
+ the evaluator version, dataset, and concurrency, with one child span per scored row.
+ On close, the run span records its status and each agreement metric as attributes, so
+ a Logfire query can filter runs by accuracy directly.
+
+ valcore config set-logfire-token
+
+ A write token belongs to one Logfire project and is created from that project's
+ settings. Create the project first, then issue the token there:
+
+
+
+ Creating write tokens
+
+
+
+ Tracing is an optional extra as well as an optional key — install it with{" "}
+ uv tool install 'valcore[logfire]'. The gateway already reports
+ the LLM calls themselves; valcore adds only the surrounding run and row context, and
+ deliberately does not re-report the calls, which would double-count tokens and cost.
+
+
+
+
+
+ The API key is a separate credential from the write token, and it is only needed for
+ one thing: pushing a dataset to Logfire's hosted dataset store.
+
+ valcore config set-logfire-key
+
+ It must carry the project:read_datasets and{" "}
+ project:write_datasets scopes. A key without them will authenticate and
+ then fail on the push. API keys are issued from your Logfire account settings:
+
+
+
+ Logfire API reference
+
+
+ valcore logfire push my-dataset
+
+
+
+
+ Keys live in ~/.valcore/config.toml, written with mode 0600,
+ and are exported into the environment when a command runs. An already-exported
+ environment variable always wins over the stored value, which is what you want in CI:
+
+ export PYDANTIC_AI_GATEWAY_API_KEY=sk-...
+
To check what is currently configured without revealing anything:
+ valcore config get
+
+ The stored key is masked unless you pass --show-key. See{" "}
+ CLI for the rest of the config group, or{" "}
+ Evals to start authoring now that setup is done.
+
+
+
+ );
+}
diff --git a/web/src/docs/content/Runs.tsx b/web/src/docs/content/Runs.tsx
new file mode 100644
index 0000000..abbf34c
--- /dev/null
+++ b/web/src/docs/content/Runs.tsx
@@ -0,0 +1,87 @@
+// The runs tab: kinds, what each metric means, comparison, and the two commands that
+// produce runs. Named Runs rather than Experiments because the UI says Runs and
+// `valcore experiment` is one specific command rather than the whole idea.
+
+import { CodeBlock, DocLink, DocNote, DocPage, DocSection } from "../primitives";
+
+export function Runs(): JSX.Element {
+ return (
+
+
+
+ A run is one evaluator version scored over one dataset, recorded with its results. The
+ version is part of the record, so a score stays attributable after the judge moves on.
+
+
Runs are started from the command line or from the launcher in the app:
+ valcore run my-evaluator my-dataset
+
+
+
+
+ An ordinary run records what the judge returned for each row. That is enough to inspect
+ behavior, spot invalid outputs, and compare two judges against each other.
+
+
+ A validation run additionally compares the judge against the dataset's labels and
+ reports agreement. It requires a fully labeled dataset, so the option is unavailable —
+ with the reason shown — when rows are still unlabeled.
+
+ What you get depends on the output field. A categorical judge produces a confusion
+ matrix: rows are the label, columns are what the judge said, and the diagonal is
+ agreement. Reading off the diagonal tells you which categories a judge confuses, which
+ a single accuracy number hides.
+
+
+ A numeric judge produces error measures instead — mean absolute error and root mean
+ square error. MAE is the typical miss; RMSE punishes large misses harder, so a gap
+ between them means a few rows are badly wrong rather than everything being slightly
+ off.
+
+
+
+
+
+ Comparison puts two runs over the same dataset side by side, disagreements first,
+ because the rows where they differ are the only ones that explain a change. Comparing
+ runs over different datasets is refused rather than rendered — the numbers would not be
+ about the same thing.
+
+
+ Open Compare to pick two, or{" "}
+ Runs to inspect one on its own.
+
+
+
+
+
+ valcore run uses valcore's own runner. valcore experiment{" "}
+ scores the same pairing through pydantic_evals.Dataset.evaluate instead,
+ which is the path to take when you want results inside the pydantic-evals ecosystem.
+
+ A validation run can fail on its own accuracy, which is what makes it usable as a
+ check rather than a report:
+
+
+ valcore run my-evaluator my-dataset --kind validation --min-accuracy 0.9
+
+
+ Progress goes to stderr and results to stdout, so redirecting stdout yields clean JSON.
+ The CLI talks to SQLite directly — runs work whether or not serve is up.
+
+
+ See CLI for the flags that shape a run.
+
+
+
+ );
+}
diff --git a/web/src/docs/primitives.test.tsx b/web/src/docs/primitives.test.tsx
new file mode 100644
index 0000000..b8fc0a4
--- /dev/null
+++ b/web/src/docs/primitives.test.tsx
@@ -0,0 +1,112 @@
+// The four primitives every content file is built from. Content prose is covered by
+// the registry smoke render; what needs real assertions is the behavior these carry:
+// a Copy button that puts the exact command on the clipboard, and links that resolve
+// to real in-app routes rather than dead anchors.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import {
+ CodeBlock,
+ DocLink,
+ DocNote,
+ DocPage,
+ DocSection,
+ ExternalLink,
+} from "./primitives";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("DocPage", () => {
+ it("renders its children", () => {
+ render(body text);
+
+ expect(screen.getByText("body text")).toBeTruthy();
+ });
+});
+
+describe("DocSection", () => {
+ it("titles the block with an h2, leaving h1 to the page header", () => {
+ render(how versions freeze);
+
+ // Level 2, not 1: DocsPage owns the single
via PageHeader, so a section
+ // heading that claimed h1 would give the page two.
+ expect(screen.getByRole("heading", { level: 2, name: "Versions" })).toBeTruthy();
+ expect(screen.getByText("how versions freeze")).toBeTruthy();
+ });
+});
+
+describe("CodeBlock", () => {
+ it("shows the command text", () => {
+ render(valcore run my-eval my-dataset);
+
+ expect(screen.getByText("valcore run my-eval my-dataset")).toBeTruthy();
+ });
+
+ it("copies the command to the clipboard", async () => {
+ const user = userEvent.setup();
+
+ // userEvent.setup() installs its own clipboard stub, so the spy must be installed
+ // after it runs, matching ExportModal.test.tsx's copy-testing convention.
+ const writeText = vi.fn(() => Promise.resolve());
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ writable: true,
+ });
+
+ render(valcore serve);
+ await user.click(screen.getByRole("button", { name: "Copy" }));
+
+ // The exact string, not a trimmed or re-indented variant: a command that does not
+ // paste verbatim is worse than no Copy button.
+ expect(writeText).toHaveBeenCalledWith("valcore serve");
+ });
+});
+
+describe("DocNote", () => {
+ it("renders its children and hides the icon from assistive tech", () => {
+ const { container } = render(labels are only needed for validation);
+
+ expect(screen.getByText("labels are only needed for validation")).toBeTruthy();
+ expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
+ });
+});
+
+describe("ExternalLink", () => {
+ it("renders a real anchor to the destination", () => {
+ // Not a router Link: react-router would treat the URL as an in-app path and route
+ // to a 404 shell instead of leaving the app.
+ render(gateway docs);
+
+ expect(screen.getByRole("link", { name: "gateway docs" }).getAttribute("href")).toBe(
+ "https://ai.pydantic.dev/gateway/",
+ );
+ });
+
+ it("opens in a new tab without leaking the opener", () => {
+ render(logfire);
+
+ const link = screen.getByRole("link", { name: "logfire" });
+ // The app is a local workspace with unsaved editor state; navigating away in the
+ // same tab would discard it.
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(link.getAttribute("rel")).toBe("noreferrer");
+ });
+});
+
+describe("DocLink", () => {
+ it("links to an in-app route", () => {
+ render(
+
+ Evaluators
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: "Evaluators" }).getAttribute("href")).toBe(
+ "/evaluators",
+ );
+ });
+});
diff --git a/web/src/docs/primitives.tsx b/web/src/docs/primitives.tsx
new file mode 100644
index 0000000..8cbeb9c
--- /dev/null
+++ b/web/src/docs/primitives.tsx
@@ -0,0 +1,88 @@
+// The vocabulary every docs content file is written in. Content files import only
+// these, so prose never carries a class name or a styling decision — the same split
+// that keeps PageHeader the sole owner of the page
.
+
+import type { ReactNode } from "react";
+import { Link } from "react-router-dom";
+import { Button } from "../components/ui";
+import { InfoIcon } from "../components/icons";
+
+// Vertical rhythm wrapper for one tab's body. Sections inside it are plain
+// elements with their own heading, so the document outline stays flat under the
+// PageHeader
that DocsPage owns.
+export function DocPage({ children }: { children: ReactNode }): JSX.Element {
+ return
{children}
;
+}
+
+// One titled block of prose. The heading is an
because the tab title is the
+// page's
, owned by PageHeader in DocsPage.
+export function DocSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}): JSX.Element {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+// A copyable command. `children` is the literal command string rather than markup so
+// the clipboard write and the rendered text can never diverge.
+export function CodeBlock({ children }: { children: string }): JSX.Element {
+ const copy = () => {
+ void navigator.clipboard.writeText(children);
+ };
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+// An aside for a caveat worth interrupting the prose for. The icon is decorative —
+// the text carries the meaning.
+export function DocNote({ children }: { children: ReactNode }): JSX.Element {
+ return (
+
+ );
+}
+
+// A link to somewhere else in the app — another docs tab or a working surface. Wraps
+// react-router's Link so docs navigation stays client-side and never reloads the SPA.
+export function DocLink({ to, children }: { to: string; children: ReactNode }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+// A link off the app entirely — provider dashboards and upstream docs. A plain anchor
+// rather than a router Link, which would treat the URL as an in-app path. Opens in a new
+// tab: this is a local workspace with unsaved editor state, and navigating away in the
+// same tab would discard it.
+export function ExternalLink({
+ href,
+ children,
+}: {
+ href: string;
+ children: ReactNode;
+}): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/docs/registry.test.tsx b/web/src/docs/registry.test.tsx
new file mode 100644
index 0000000..f6efdb4
--- /dev/null
+++ b/web/src/docs/registry.test.tsx
@@ -0,0 +1,75 @@
+// The registry is the single source for tab order, slugs, titles, and bodies, so the
+// checks here are the ones that would otherwise fail at runtime as a blank tab: a
+// duplicate slug (two tabs, one reachable), an empty title (an unlabelled tab), or a
+// content file that throws (a blank pane under a working tab strip).
+//
+// A .tsx suite rather than .ts: every body may render a DocLink, which needs router
+// context, so the smoke render wraps each body in a MemoryRouter.
+import { afterEach, describe, expect, it } from "vitest";
+import { cleanup, render } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { DOCS, resolveDoc } from "./registry";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("docs registry", () => {
+ it("lists the five documented tabs in order", () => {
+ // Keys leads: nothing that calls a model runs without the gateway key, so it is
+ // both the first thing a new user needs and the tab /docs lands on.
+ expect(DOCS.map((entry) => entry.slug)).toEqual([
+ "keys",
+ "evals",
+ "datasets",
+ "runs",
+ "cli",
+ ]);
+ });
+
+ it("gives every entry a unique slug", () => {
+ const slugs = DOCS.map((entry) => entry.slug);
+ expect(new Set(slugs).size).toBe(slugs.length);
+ });
+
+ it("gives every entry a non-empty slug and title", () => {
+ for (const entry of DOCS) {
+ expect(entry.slug.length).toBeGreaterThan(0);
+ expect(entry.title.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("resolves a known slug to its entry", () => {
+ expect(resolveDoc("runs").title).toBe("Runs");
+ });
+
+ it("lands on Keys for the bare route", () => {
+ expect(resolveDoc(undefined).title).toBe("Keys");
+ });
+
+ it("falls back to the first tab for an unknown slug", () => {
+ // A renamed or mistyped slug must land on readable content rather than a blank
+ // pane under a working tab strip.
+ expect(resolveDoc("does-not-exist")).toBe(DOCS[0]);
+ });
+
+ it("falls back to the first tab when no slug is given", () => {
+ // This is the bare /docs route, which carries no :slug param.
+ expect(resolveDoc(undefined)).toBe(DOCS[0]);
+ });
+
+ it.each(DOCS.map((entry) => [entry.slug, entry] as const))(
+ "%s renders its body without throwing",
+ (_slug, entry) => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ // Rendered *something*: an empty body is a blank tab, which the tab strip
+ // would happily present as working.
+ expect(container.textContent?.trim().length).toBeGreaterThan(0);
+ },
+ );
+});
diff --git a/web/src/docs/registry.ts b/web/src/docs/registry.ts
new file mode 100644
index 0000000..9ae8147
--- /dev/null
+++ b/web/src/docs/registry.ts
@@ -0,0 +1,40 @@
+// The single source of truth for the docs section: array order is tab order, and the
+// tab strip, the routes, and the page titles all read from here. Keeping them in one
+// list is what stops a tab existing with no reachable content, or a route existing with
+// no tab pointing at it.
+//
+// Adding a page: write the content component, import it, append an entry.
+
+import type { ComponentType } from "react";
+import { Cli } from "./content/Cli";
+import { Datasets } from "./content/Datasets";
+import { Evals } from "./content/Evals";
+import { Keys } from "./content/Keys";
+import { Runs } from "./content/Runs";
+
+export type DocEntry = {
+ /** URL segment under /docs. Stable — it is what people paste to each other. */
+ slug: string;
+ /** Tab label, and the page
that DocsPage renders. */
+ title: string;
+ /** The prose. Takes no props: docs render from static content, never from fetches. */
+ Body: ComponentType;
+};
+
+export const DOCS: DocEntry[] = [
+ // Keys leads, and so is what /docs lands on: nothing that calls a model runs until
+ // the gateway key exists, which makes it the first thing a new user needs.
+ { slug: "keys", title: "Keys", Body: Keys },
+ { slug: "evals", title: "Evals", Body: Evals },
+ { slug: "datasets", title: "Datasets", Body: Datasets },
+ { slug: "runs", title: "Runs", Body: Runs },
+ { slug: "cli", title: "CLI", Body: Cli },
+];
+
+// Resolving in code rather than redirecting: an unknown slug renders the first tab and
+// leaves the URL alone, so a stale link degrades to something readable instead of a
+// blank pane, and no history entry is spent on the correction. The bare /docs route
+// arrives here as `undefined` and lands in the same place.
+export function resolveDoc(slug: string | undefined): DocEntry {
+ return DOCS.find((entry) => entry.slug === slug) ?? DOCS[0];
+}
diff --git a/web/src/pages/DocsPage.test.tsx b/web/src/pages/DocsPage.test.tsx
new file mode 100644
index 0000000..808a042
--- /dev/null
+++ b/web/src/pages/DocsPage.test.tsx
@@ -0,0 +1,114 @@
+// DocsPage owns routing and chrome, not content, so these tests are about which entry
+// is selected and how it is reachable — never about the prose, which registry.test.tsx
+// covers.
+import { afterEach, describe, expect, it } from "vitest";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import DocsPage from "./DocsPage";
+import { DOCS } from "../docs/registry";
+
+afterEach(() => {
+ cleanup();
+});
+
+// Both routes point at the same component, so the harness mounts both: the bare /docs
+// path carries no :slug, which is its own resolution case.
+function renderDocs(path: string) {
+ return render(
+
+
+ } />
+ } />
+
+ ,
+ );
+}
+
+// Content prose links to other tabs and to working surfaces, so several body links share
+// a name with a tab. Every tab assertion is scoped to the strip's