Merge pull request #703 from AssetOverflow/feat/wb-primitives

feat(workbench): six evidence primitives
This commit is contained in:
Shay 2026-06-12 07:04:29 -07:00
commit 1630de6047
14 changed files with 1217 additions and 10 deletions

View file

@ -104,17 +104,16 @@ route proves it requires the component.
For each component:
- [ ] Built with design tokens only (no raw hex/rgb)
- [ ] Motion via `--motion-duration-*` and `--motion-ease-*` tokens
- [ ] `prefers-reduced-motion` collapses to instant
- [ ] `:focus-visible` ring via `--color-focus-ring`
- [ ] Renders in PreviewPage (`/preview`)
- [ ] Unit test
- [x] Built with design tokens only (no raw hex/rgb)
- [x] Motion via `--motion-duration-*` and `--motion-ease-*` tokens
- [x] `prefers-reduced-motion` collapses to instant (global rule in tokens.css)
- [x] `:focus-visible` ring via `--color-focus-ring`
- [x] Renders in PreviewPage (`/preview`)
- [x] Unit test
**TabBar dependency note:** `@radix-ui/react-tabs` is not currently in
`package.json` (only `react-dialog` and `react-popover` are). Either add the
dependency explicitly with lockfile update, or implement TabBar with native
ARIA tab semantics without Radix. Decide at implementation time.
**TabBar dependency decision:** Implemented with native ARIA tab semantics
(no `@radix-ui/react-tabs` added). Full `role="tablist"`/`role="tab"`/
`role="tabpanel"` with `ArrowLeft`/`ArrowRight`/`Home`/`End` keyboard nav.
**Deferred primitives** (build when a route needs them):
- DataTable, TreeView, Timeline, CodeViewer, Drawer, Toast, SkeletonLoader, Kbd

View file

@ -0,0 +1,62 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { DigestBadge } from "./DigestBadge";
const HASH = "4f80f7e12c7e8ca1f1a277f8ccecf2846f08bb9d8f22354e6d3f30eb7fb34c80";
describe("DigestBadge", () => {
it("renders truncated digest with algorithm prefix", () => {
render(<DigestBadge digest={HASH} />);
const badge = screen.getByTestId("digest-badge");
expect(badge).toHaveTextContent("sha256:4f80f7e12c7e8ca1...");
});
it("uses custom algorithm prefix", () => {
render(<DigestBadge digest={HASH} algorithm="blake3" />);
expect(screen.getByTestId("digest-badge")).toHaveTextContent("blake3:");
});
it("uses custom truncation length", () => {
render(<DigestBadge digest={HASH} truncate={8} />);
expect(screen.getByTestId("digest-badge")).toHaveTextContent("sha256:4f80f7e1...");
});
it("copies full digest to clipboard on click", () => {
render(<DigestBadge digest={HASH} />);
fireEvent.click(screen.getByTestId("digest-badge"));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`sha256:${HASH}`);
});
it("has aria-label with full digest", () => {
render(<DigestBadge digest={HASH} />);
const badge = screen.getByTestId("digest-badge");
expect(badge.getAttribute("aria-label")).toContain(HASH);
});
it("shows green dot when verified is true", () => {
render(<DigestBadge digest={HASH} verified={true} />);
expect(screen.getByLabelText("Verified")).toBeInTheDocument();
});
it("shows red dot when verified is false", () => {
render(<DigestBadge digest={HASH} verified={false} />);
expect(screen.getByLabelText("Not verified")).toBeInTheDocument();
});
it("shows gray dot when verified is null", () => {
render(<DigestBadge digest={HASH} verified={null} />);
expect(screen.getByLabelText("Verification unknown")).toBeInTheDocument();
});
it("does not show dot when verified is undefined", () => {
render(<DigestBadge digest={HASH} />);
expect(screen.queryByLabelText("Verified")).toBeNull();
expect(screen.queryByLabelText("Not verified")).toBeNull();
expect(screen.queryByLabelText("Verification unknown")).toBeNull();
});
it("does not truncate short digests", () => {
render(<DigestBadge digest="abc123" truncate={16} />);
expect(screen.getByTestId("digest-badge")).toHaveTextContent("sha256:abc123");
expect(screen.getByTestId("digest-badge")).not.toHaveTextContent("...");
});
});

View file

@ -0,0 +1,76 @@
import { useState } from "react";
import { copyText } from "../../lib";
export interface DigestBadgeProps {
digest: string;
algorithm?: string;
verified?: boolean | null;
truncate?: number;
}
function VerifiedDot({ verified }: { verified: boolean | null }) {
const color =
verified === true
? "var(--color-state-verified)"
: verified === false
? "var(--color-state-contradicted)"
: "var(--color-text-muted)";
const label =
verified === true
? "Verified"
: verified === false
? "Not verified"
: "Verification unknown";
return (
<span
aria-label={label}
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: color }}
/>
);
}
export function DigestBadge({
digest,
algorithm = "sha256",
verified,
truncate = 16,
}: DigestBadgeProps) {
const [copied, setCopied] = useState(false);
const display = digest.length > truncate
? `${digest.slice(0, truncate)}...`
: digest;
const fullDisplay = `${algorithm}:${display}`;
return (
<button
type="button"
aria-label={`Digest ${algorithm}:${digest}. Click to copy.`}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-xs transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
borderColor: "var(--color-border-subtle)",
background: "var(--color-surface-inset)",
color: "var(--color-text-mono)",
cursor: "pointer",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
transitionDuration: "var(--motion-duration-fast)",
transitionTimingFunction: "var(--motion-ease-standard)",
}}
onClick={() => {
void copyText(`${algorithm}:${digest}`).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
data-testid="digest-badge"
>
{verified !== undefined && <VerifiedDot verified={verified} />}
<span>{copied ? "Copied" : fullDisplay}</span>
</button>
);
}

View file

@ -0,0 +1,79 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { MetadataTable } from "./MetadataTable";
describe("MetadataTable", () => {
it("renders key-value pairs", () => {
render(
<MetadataTable
rows={[
{ key: "trace_hash", value: "abc123" },
{ key: "cost_ms", value: "17" },
]}
/>,
);
expect(screen.getByText("trace_hash")).toBeInTheDocument();
expect(screen.getByText("abc123")).toBeInTheDocument();
expect(screen.getByText("cost_ms")).toBeInTheDocument();
expect(screen.getByText("17")).toBeInTheDocument();
});
it("renders rows in array order", () => {
render(
<MetadataTable
rows={[
{ key: "z_last", value: "1" },
{ key: "a_first", value: "2" },
]}
/>,
);
const dts = screen.getAllByRole("term");
expect(dts[0]).toHaveTextContent("z_last");
expect(dts[1]).toHaveTextContent("a_first");
});
it("shows copy button on hover for copyable rows", () => {
render(
<MetadataTable
rows={[{ key: "hash", value: "abc123def", copyable: true }]}
/>,
);
const copyBtn = screen.getByLabelText("Copy abc123def");
expect(copyBtn).toBeInTheDocument();
});
it("copies value to clipboard when copy button clicked", () => {
render(
<MetadataTable
rows={[{ key: "hash", value: "abc123def", copyable: true }]}
/>,
);
const copyBtn = screen.getByLabelText("Copy abc123def");
fireEvent.click(copyBtn);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("abc123def");
});
it("does not show copy button for non-copyable rows", () => {
render(
<MetadataTable
rows={[{ key: "status", value: "ok" }]}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
});
it("renders ReactNode values", () => {
render(
<MetadataTable
rows={[{ key: "badge", value: <span data-testid="custom-badge">Custom</span> }]}
/>,
);
expect(screen.getByTestId("custom-badge")).toBeInTheDocument();
});
it("uses definition list semantics", () => {
render(
<MetadataTable rows={[{ key: "k", value: "v" }]} />,
);
expect(screen.getByTestId("metadata-table").tagName).toBe("DL");
});
});

View file

@ -0,0 +1,82 @@
import { type ReactNode, useState } from "react";
import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib";
export interface MetadataRow {
key: string;
value: ReactNode;
copyable?: boolean;
mono?: boolean;
}
export interface MetadataTableProps {
rows: readonly MetadataRow[];
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
aria-label={`Copy ${text}`}
className="ml-1 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
transitionDuration: "var(--motion-duration-fast)",
transitionTimingFunction: "var(--motion-ease-standard)",
background: "none",
border: "none",
cursor: "pointer",
color: "var(--color-text-muted)",
padding: 0,
}}
onClick={() => {
void copyText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
>
{copied ? (
<Check size={12} aria-hidden />
) : (
<Copy size={12} aria-hidden />
)}
</button>
);
}
export function MetadataTable({ rows }: MetadataTableProps) {
return (
<dl
className="m-0 grid gap-0"
data-testid="metadata-table"
>
{rows.map((row) => (
<div
key={row.key}
className="group flex items-baseline gap-3 border-b border-[var(--color-border-subtle)] px-1 py-2 last:border-b-0"
>
<dt
className="m-0 w-36 shrink-0 text-xs text-[var(--color-text-secondary)]"
style={{ fontSize: "var(--text-xs)" }}
>
{row.key}
</dt>
<dd
className="m-0 flex items-center text-sm text-[var(--color-text-primary)]"
style={{
fontSize: "var(--text-sm)",
fontFamily: row.mono ? "var(--font-mono)" : undefined,
}}
>
{row.value}
{row.copyable && typeof row.value === "string" && (
<CopyButton text={row.value} />
)}
</dd>
</div>
))}
</dl>
);
}

View file

@ -0,0 +1,92 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { useState } from "react";
import { SearchInput } from "./SearchInput";
function TestSearchInput() {
const [value, setValue] = useState("");
return (
<SearchInput
placeholder="Search turns..."
value={value}
onChange={setValue}
/>
);
}
describe("SearchInput", () => {
it("renders an input with placeholder", () => {
render(<TestSearchInput />);
expect(screen.getByPlaceholderText("Search turns...")).toBeInTheDocument();
});
it("has aria-label matching placeholder", () => {
render(<TestSearchInput />);
expect(screen.getByLabelText("Search turns...")).toBeInTheDocument();
});
it("shows shortcut hint when empty", () => {
render(<TestSearchInput />);
expect(screen.getByText("/")).toBeInTheDocument();
});
it("focuses input on shortcut key press", () => {
render(<TestSearchInput />);
const input = screen.getByLabelText("Search turns...");
fireEvent.keyDown(window, { key: "/" });
expect(document.activeElement).toBe(input);
});
it("does not focus when typing in another input", () => {
render(
<>
<input data-testid="other-input" />
<TestSearchInput />
</>,
);
const other = screen.getByTestId("other-input");
other.focus();
fireEvent.keyDown(other, { key: "/" });
expect(document.activeElement).toBe(other);
});
it("shows clear button when value is non-empty", () => {
render(
<SearchInput
placeholder="Search"
value="hello"
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("Clear search")).toBeInTheDocument();
});
it("does not show clear button when value is empty", () => {
render(<TestSearchInput />);
expect(screen.queryByLabelText("Clear search")).toBeNull();
});
it("clears value on clear button click", () => {
const onChange = vi.fn();
render(
<SearchInput
placeholder="Search"
value="hello"
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("Clear search"));
expect(onChange).toHaveBeenCalledWith("");
});
it("uses custom shortcut key", () => {
render(
<SearchInput
placeholder="Search"
value=""
onChange={() => {}}
shortcut="s"
/>,
);
expect(screen.getByText("s")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,105 @@
import { useEffect, useRef, useCallback } from "react";
import { Search, X } from "lucide-react";
export interface SearchInputProps {
placeholder: string;
value: string;
onChange: (value: string) => void;
shortcut?: string;
}
export function SearchInput({
placeholder,
value,
onChange,
shortcut = "/",
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleChange = useCallback(
(raw: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => onChange(raw), 150);
},
[onChange],
);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== shortcut) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || target?.isContentEditable) return;
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [shortcut]);
return (
<div
className="relative flex items-center"
data-testid="search-input"
>
<Search
size={14}
className="absolute left-2 text-[var(--color-text-muted)]"
aria-hidden
/>
<input
ref={inputRef}
type="search"
aria-label={placeholder}
placeholder={placeholder}
defaultValue={value}
onChange={(e) => handleChange(e.target.value)}
className="w-full rounded-md border bg-transparent py-1.5 pl-7 pr-7 text-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
borderColor: "var(--color-border-subtle)",
color: "var(--color-text-primary)",
fontSize: "var(--text-sm)",
transitionDuration: "var(--motion-duration-fast)",
transitionTimingFunction: "var(--motion-ease-standard)",
}}
/>
{value && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
onChange("");
if (inputRef.current) inputRef.current.value = "";
inputRef.current?.focus();
}}
className="absolute right-2 flex items-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
<X size={14} aria-hidden />
</button>
)}
<kbd
className="absolute right-2 rounded border px-1 text-[10px] text-[var(--color-text-muted)]"
style={{
borderColor: "var(--color-border-subtle)",
display: value ? "none" : undefined,
}}
aria-hidden
>
{shortcut}
</kbd>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { SplitPane } from "./SplitPane";
beforeEach(() => {
localStorage.clear();
});
describe("SplitPane", () => {
it("renders two children", () => {
render(
<SplitPane direction="horizontal">
<div>Left</div>
<div>Right</div>
</SplitPane>,
);
expect(screen.getByText("Left")).toBeInTheDocument();
expect(screen.getByText("Right")).toBeInTheDocument();
});
it("renders a separator with correct orientation for horizontal", () => {
render(
<SplitPane direction="horizontal">
<div>A</div>
<div>B</div>
</SplitPane>,
);
const handle = screen.getByRole("separator");
expect(handle).toHaveAttribute("aria-orientation", "vertical");
});
it("renders a separator with correct orientation for vertical", () => {
render(
<SplitPane direction="vertical">
<div>A</div>
<div>B</div>
</SplitPane>,
);
const handle = screen.getByRole("separator");
expect(handle).toHaveAttribute("aria-orientation", "horizontal");
});
it("adjusts split via keyboard (ArrowRight for horizontal)", () => {
render(
<SplitPane direction="horizontal" defaultSplit={50}>
<div>A</div>
<div>B</div>
</SplitPane>,
);
const handle = screen.getByRole("separator");
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(handle).toHaveAttribute("aria-valuenow", "52");
});
it("adjusts split via keyboard (ArrowLeft for horizontal)", () => {
render(
<SplitPane direction="horizontal" defaultSplit={50}>
<div>A</div>
<div>B</div>
</SplitPane>,
);
const handle = screen.getByRole("separator");
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(handle).toHaveAttribute("aria-valuenow", "48");
});
it("persists split to localStorage when id is provided", () => {
render(
<SplitPane direction="horizontal" defaultSplit={40} id="test-split">
<div>A</div>
<div>B</div>
</SplitPane>,
);
expect(localStorage.getItem("core-split-test-split")).toBe("40");
});
it("restores split from localStorage", () => {
localStorage.setItem("core-split-restore-test", "65");
render(
<SplitPane direction="horizontal" id="restore-test">
<div>A</div>
<div>B</div>
</SplitPane>,
);
const handle = screen.getByRole("separator");
expect(handle).toHaveAttribute("aria-valuenow", "65");
});
it("is focusable via tabIndex", () => {
render(
<SplitPane direction="horizontal">
<div>A</div>
<div>B</div>
</SplitPane>,
);
const handle = screen.getByRole("separator");
expect(handle).toHaveAttribute("tabIndex", "0");
});
});

View file

@ -0,0 +1,144 @@
import {
type ReactNode,
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from "react";
export interface SplitPaneProps {
direction: "horizontal" | "vertical";
defaultSplit?: number;
minSize?: number;
id?: string;
children: [ReactNode, ReactNode];
}
function storageKey(id: string) {
return `core-split-${id}`;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
export function SplitPane({
direction,
defaultSplit = 50,
minSize = 120,
id,
children,
}: SplitPaneProps) {
const [split, setSplit] = useState(() => {
if (id) {
const stored = localStorage.getItem(storageKey(id));
if (stored !== null) {
const parsed = Number(stored);
if (!Number.isNaN(parsed)) return parsed;
}
}
return defaultSplit;
});
const containerRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
useEffect(() => {
if (id) localStorage.setItem(storageKey(id), String(split));
}, [split, id]);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
dragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const isHorizontal = direction === "horizontal";
const pos = isHorizontal ? e.clientX - rect.left : e.clientY - rect.top;
const total = isHorizontal ? rect.width : rect.height;
if (total === 0) return;
const minPct = (minSize / total) * 100;
const pct = clamp((pos / total) * 100, minPct, 100 - minPct);
setSplit(pct);
},
[direction, minSize],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
const isHorizontal = direction === "horizontal";
const containerStyle: CSSProperties = {
display: "flex",
flexDirection: isHorizontal ? "row" : "column",
width: "100%",
height: "100%",
overflow: "hidden",
};
const firstStyle: CSSProperties = isHorizontal
? { width: `${split}%`, minWidth: minSize, overflow: "auto" }
: { height: `${split}%`, minHeight: minSize, overflow: "auto" };
const secondStyle: CSSProperties = isHorizontal
? { flex: 1, minWidth: minSize, overflow: "auto" }
: { flex: 1, minHeight: minSize, overflow: "auto" };
const handleStyle: CSSProperties = {
flexShrink: 0,
cursor: isHorizontal ? "col-resize" : "row-resize",
background: "var(--color-border-subtle)",
transition: `background var(--motion-duration-fast) var(--motion-ease-standard)`,
...(isHorizontal
? { width: 4, minHeight: "100%" }
: { height: 4, minWidth: "100%" }),
};
return (
<div ref={containerRef} style={containerStyle} data-testid="split-pane">
<div style={firstStyle} data-testid="split-pane-first">
{children[0]}
</div>
<div
role="separator"
aria-orientation={isHorizontal ? "vertical" : "horizontal"}
aria-valuenow={Math.round(split)}
tabIndex={0}
style={handleStyle}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={(e) => {
const step = 2;
if (
(isHorizontal && e.key === "ArrowLeft") ||
(!isHorizontal && e.key === "ArrowUp")
) {
e.preventDefault();
setSplit((s) => clamp(s - step, 5, 95));
} else if (
(isHorizontal && e.key === "ArrowRight") ||
(!isHorizontal && e.key === "ArrowDown")
) {
e.preventDefault();
setSplit((s) => clamp(s + step, 5, 95));
}
}}
className="hover:bg-[var(--color-border-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
data-testid="split-pane-handle"
/>
<div style={secondStyle} data-testid="split-pane-second">
{children[1]}
</div>
</div>
);
}

View file

@ -0,0 +1,99 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { useState } from "react";
import { TabBar, type Tab } from "./TabBar";
const TABS: Tab[] = [
{ id: "surfaces", label: "Surfaces" },
{ id: "grounding", label: "Grounding" },
{ id: "verdicts", label: "Verdicts" },
];
function TestTabBar({ initialTab = "surfaces" }: { initialTab?: string }) {
const [active, setActive] = useState(initialTab);
return (
<TabBar tabs={TABS} activeTab={active} onTabChange={setActive}>
<div data-testid={`panel-${active}`}>Content for {active}</div>
</TabBar>
);
}
describe("TabBar", () => {
it("renders all tabs", () => {
render(<TestTabBar />);
expect(screen.getByRole("tab", { name: "Surfaces" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Grounding" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Verdicts" })).toBeInTheDocument();
});
it("marks the active tab with aria-selected", () => {
render(<TestTabBar />);
expect(screen.getByRole("tab", { name: "Surfaces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Grounding" })).toHaveAttribute("aria-selected", "false");
});
it("renders a tabpanel with correct aria-labelledby", () => {
render(<TestTabBar />);
const panel = screen.getByRole("tabpanel");
expect(panel).toHaveAttribute("aria-labelledby", "tab-surfaces");
});
it("changes tab on click", () => {
render(<TestTabBar />);
fireEvent.click(screen.getByRole("tab", { name: "Grounding" }));
expect(screen.getByRole("tab", { name: "Grounding" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("panel-grounding")).toBeInTheDocument();
});
it("navigates with ArrowRight", () => {
render(<TestTabBar />);
const first = screen.getByRole("tab", { name: "Surfaces" });
fireEvent.keyDown(first, { key: "ArrowRight" });
expect(screen.getByRole("tab", { name: "Grounding" })).toHaveAttribute("aria-selected", "true");
});
it("wraps around with ArrowRight from last", () => {
render(<TestTabBar initialTab="verdicts" />);
const last = screen.getByRole("tab", { name: "Verdicts" });
fireEvent.keyDown(last, { key: "ArrowRight" });
expect(screen.getByRole("tab", { name: "Surfaces" })).toHaveAttribute("aria-selected", "true");
});
it("navigates with ArrowLeft", () => {
render(<TestTabBar initialTab="grounding" />);
const tab = screen.getByRole("tab", { name: "Grounding" });
fireEvent.keyDown(tab, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Surfaces" })).toHaveAttribute("aria-selected", "true");
});
it("wraps around with ArrowLeft from first", () => {
render(<TestTabBar />);
const first = screen.getByRole("tab", { name: "Surfaces" });
fireEvent.keyDown(first, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Verdicts" })).toHaveAttribute("aria-selected", "true");
});
it("Home jumps to first tab", () => {
render(<TestTabBar initialTab="verdicts" />);
const tab = screen.getByRole("tab", { name: "Verdicts" });
fireEvent.keyDown(tab, { key: "Home" });
expect(screen.getByRole("tab", { name: "Surfaces" })).toHaveAttribute("aria-selected", "true");
});
it("End jumps to last tab", () => {
render(<TestTabBar />);
const first = screen.getByRole("tab", { name: "Surfaces" });
fireEvent.keyDown(first, { key: "End" });
expect(screen.getByRole("tab", { name: "Verdicts" })).toHaveAttribute("aria-selected", "true");
});
it("inactive tabs have tabIndex -1", () => {
render(<TestTabBar />);
expect(screen.getByRole("tab", { name: "Grounding" })).toHaveAttribute("tabIndex", "-1");
expect(screen.getByRole("tab", { name: "Verdicts" })).toHaveAttribute("tabIndex", "-1");
});
it("has a tablist role container", () => {
render(<TestTabBar />);
expect(screen.getByRole("tablist")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,115 @@
import { useRef, useCallback, type ReactNode, type KeyboardEvent } from "react";
export interface Tab {
id: string;
label: string;
}
export interface TabBarProps {
tabs: readonly Tab[];
activeTab: string;
onTabChange: (id: string) => void;
children: ReactNode;
}
export function TabBar({ tabs, activeTab, onTabChange, children }: TabBarProps) {
const tablistRef = useRef<HTMLDivElement>(null);
const focusTab = useCallback(
(index: number) => {
const tablist = tablistRef.current;
if (!tablist) return;
const buttons = tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]');
buttons[index]?.focus();
onTabChange(tabs[index].id);
},
[onTabChange, tabs],
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
const currentIndex = tabs.findIndex((t) => t.id === activeTab);
if (currentIndex === -1) return;
switch (e.key) {
case "ArrowRight": {
e.preventDefault();
focusTab((currentIndex + 1) % tabs.length);
break;
}
case "ArrowLeft": {
e.preventDefault();
focusTab((currentIndex - 1 + tabs.length) % tabs.length);
break;
}
case "Home": {
e.preventDefault();
focusTab(0);
break;
}
case "End": {
e.preventDefault();
focusTab(tabs.length - 1);
break;
}
}
},
[tabs, activeTab, focusTab],
);
return (
<div data-testid="tab-bar">
<div
ref={tablistRef}
role="tablist"
className="flex gap-0 border-b border-[var(--color-border-subtle)]"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
role="tab"
type="button"
id={`tab-${tab.id}`}
aria-selected={isActive}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onTabChange(tab.id)}
className="relative px-3 py-2 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
color: isActive
? "var(--color-text-primary)"
: "var(--color-text-muted)",
background: "transparent",
border: "none",
cursor: "pointer",
transitionDuration: "var(--motion-duration-fast)",
transitionTimingFunction: "var(--motion-ease-standard)",
}}
>
{tab.label}
{isActive && (
<span
className="absolute bottom-0 left-0 right-0 h-[2px]"
style={{ background: "var(--color-focus-ring)" }}
aria-hidden
/>
)}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`tabpanel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
tabIndex={0}
className="py-3"
>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { Timestamp } from "./Timestamp";
const ISO = "2026-06-12T10:30:00Z";
describe("Timestamp", () => {
it("renders a <time> element with datetime attribute", () => {
render(<Timestamp iso={ISO} />);
const el = screen.getByTestId("timestamp");
expect(el.tagName).toBe("TIME");
expect(el).toHaveAttribute("datetime", ISO);
});
it("renders absolute format", () => {
render(<Timestamp iso={ISO} format="absolute" />);
const el = screen.getByTestId("timestamp");
expect(el.textContent).toMatch(/Jun/);
expect(el.textContent).toMatch(/2026/);
});
it("renders relative format", () => {
const now = new Date();
const fiveMinAgo = new Date(now.getTime() - 5 * 60_000).toISOString();
render(<Timestamp iso={fiveMinAgo} format="relative" />);
const el = screen.getByTestId("timestamp");
expect(el.textContent).toMatch(/\d+m ago/);
});
it("renders both formats by default", () => {
const now = new Date();
const twoHoursAgo = new Date(now.getTime() - 2 * 3_600_000).toISOString();
render(<Timestamp iso={twoHoursAgo} />);
const el = screen.getByTestId("timestamp");
expect(el.textContent).toMatch(/2h ago/);
expect(el.textContent).toMatch(/\d{4}/);
});
it("shows 'just now' for very recent timestamps", () => {
const now = new Date().toISOString();
render(<Timestamp iso={now} format="relative" />);
expect(screen.getByTestId("timestamp")).toHaveTextContent("just now");
});
it("shows 'yesterday' for timestamps about 1 day old", () => {
const yesterday = new Date(Date.now() - 30 * 3_600_000).toISOString();
render(<Timestamp iso={yesterday} format="relative" />);
expect(screen.getByTestId("timestamp")).toHaveTextContent("yesterday");
});
it("has a title tooltip", () => {
render(<Timestamp iso={ISO} format="relative" />);
const el = screen.getByTestId("timestamp");
expect(el.title).toMatch(/Jun/);
});
});

View file

@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
export interface TimestampProps {
iso: string;
format?: "relative" | "absolute" | "both";
}
const LA_TZ = "America/Los_Angeles";
const MINUTE = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
function formatAbsolute(date: Date): string {
return date.toLocaleString("en-US", {
timeZone: LA_TZ,
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
function formatRelative(date: Date, now: Date): string {
const diff = now.getTime() - date.getTime();
if (diff < 0) return "just now";
if (diff < MINUTE) return "just now";
if (diff < HOUR) {
const mins = Math.floor(diff / MINUTE);
return `${mins}m ago`;
}
if (diff < DAY) {
const hours = Math.floor(diff / HOUR);
return `${hours}h ago`;
}
if (diff < 2 * DAY) return "yesterday";
const days = Math.floor(diff / DAY);
if (days < 30) return `${days}d ago`;
return formatAbsolute(date);
}
export function Timestamp({ iso, format = "both" }: TimestampProps) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
if (format === "absolute") return;
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, [format]);
const date = new Date(iso);
const abs = formatAbsolute(date);
const rel = formatRelative(date, now);
const displayed = format === "absolute" ? abs : format === "relative" ? rel : rel;
const tooltip = format === "absolute" ? rel : abs;
return (
<time
dateTime={iso}
title={tooltip}
className="whitespace-nowrap text-xs text-[var(--color-text-secondary)]"
style={{ fontSize: "var(--text-xs)" }}
data-testid="timestamp"
>
{displayed}
{format === "both" && (
<span className="ml-1.5 text-[var(--color-text-muted)]">{abs}</span>
)}
</time>
);
}

View file

@ -16,9 +16,17 @@ import { CommandPalette } from "../design/components/primitives/CommandPalette";
import { EmptyState } from "../design/components/states/EmptyState";
import { ErrorState } from "../design/components/states/ErrorState";
import { LoadingState } from "../design/components/states/LoadingState";
import { SplitPane } from "../design/components/SplitPane/SplitPane";
import { TabBar } from "../design/components/TabBar/TabBar";
import { MetadataTable } from "../design/components/MetadataTable/MetadataTable";
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
import { Timestamp } from "../design/components/Timestamp/Timestamp";
import { SearchInput } from "../design/components/SearchInput/SearchInput";
export function PreviewPage() {
const [paletteOpen, setPaletteOpen] = useState(false);
const [activeTab, setActiveTab] = useState("surfaces");
const [searchValue, setSearchValue] = useState("");
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@ -74,6 +82,125 @@ export function PreviewPage() {
</div>
</section>
<section aria-labelledby="splitpane-heading" className="grid gap-3">
<h2 id="splitpane-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">SplitPane</h2>
<div className="h-48 rounded-lg border border-[var(--color-border-subtle)]">
<SplitPane direction="horizontal" defaultSplit={35} id="preview-split">
<div className="flex h-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Left pane (35%)
</div>
<div className="flex h-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Right pane (65%)
</div>
</SplitPane>
</div>
<div className="h-48 rounded-lg border border-[var(--color-border-subtle)]">
<SplitPane direction="vertical" defaultSplit={40} id="preview-split-v">
<div className="flex h-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Top pane (40%)
</div>
<div className="flex h-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Bottom pane (60%)
</div>
</SplitPane>
</div>
</section>
<section aria-labelledby="tabbar-heading" className="grid gap-3">
<h2 id="tabbar-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">TabBar</h2>
<div className="rounded-lg border border-[var(--color-border-subtle)] p-3">
<TabBar
tabs={[
{ id: "surfaces", label: "Surfaces" },
{ id: "grounding", label: "Grounding" },
{ id: "verdicts", label: "Verdicts" },
{ id: "metadata", label: "Metadata" },
{ id: "raw", label: "Raw" },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
>
<div className="text-sm text-[var(--color-text-secondary)]">
Active tab: <span className="text-[var(--color-text-primary)]">{activeTab}</span>
</div>
</TabBar>
</div>
</section>
<section aria-labelledby="metadata-heading" className="grid gap-3">
<h2 id="metadata-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">MetadataTable</h2>
<div className="max-w-lg rounded-lg border border-[var(--color-border-subtle)] p-3">
<MetadataTable
rows={[
{ key: "trace_hash", value: "4f80f7e12c7e8ca1", copyable: true, mono: true },
{ key: "grounding_source", value: "teaching" },
{ key: "epistemic_state", value: "decoded" },
{ key: "turn_cost_ms", value: "17", mono: true },
{ key: "checkpoint_emitted", value: "true" },
]}
/>
</div>
</section>
<section aria-labelledby="digest-heading" className="grid gap-3">
<h2 id="digest-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">DigestBadge</h2>
<div className="flex flex-wrap items-center gap-3">
<DigestBadge
digest="4f80f7e12c7e8ca1f1a277f8ccecf2846f08bb9d8f22354e6d3f30eb7fb34c80"
verified={true}
/>
<DigestBadge
digest="deadbeef00000000000000000000000000000000000000000000000000000000"
verified={false}
truncate={12}
/>
<DigestBadge
digest="aabbccdd"
verified={null}
algorithm="blake3"
/>
<DigestBadge digest="abc123" />
</div>
</section>
<section aria-labelledby="timestamp-heading" className="grid gap-3">
<h2 id="timestamp-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">Timestamp</h2>
<div className="flex flex-wrap items-center gap-6">
<div className="grid gap-1">
<span className="text-xs text-[var(--color-text-muted)]">both (default)</span>
<Timestamp iso={new Date(Date.now() - 3 * 60_000).toISOString()} />
</div>
<div className="grid gap-1">
<span className="text-xs text-[var(--color-text-muted)]">relative</span>
<Timestamp iso={new Date(Date.now() - 2 * 3_600_000).toISOString()} format="relative" />
</div>
<div className="grid gap-1">
<span className="text-xs text-[var(--color-text-muted)]">absolute</span>
<Timestamp iso="2026-06-12T10:30:00Z" format="absolute" />
</div>
<div className="grid gap-1">
<span className="text-xs text-[var(--color-text-muted)]">yesterday</span>
<Timestamp iso={new Date(Date.now() - 28 * 3_600_000).toISOString()} format="relative" />
</div>
</div>
</section>
<section aria-labelledby="search-heading" className="grid gap-3">
<h2 id="search-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">SearchInput</h2>
<div className="max-w-sm">
<SearchInput
placeholder="Search turns by prompt or trace hash..."
value={searchValue}
onChange={setSearchValue}
/>
</div>
{searchValue && (
<p className="text-xs text-[var(--color-text-muted)]">
Filtering: &ldquo;{searchValue}&rdquo;
</p>
)}
</section>
<section aria-labelledby="json-heading" className="grid gap-3">
<h2 id="json-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">Stable JSON Viewer</h2>
<StableJsonViewer source='{"trace_hash":"4f80f7e12c7e","scenes":[{"detail":{"object":"lens","epsilon":1e-6}}],"state":"decoded"}' />