Merge pull request #703 from AssetOverflow/feat/wb-primitives
feat(workbench): six evidence primitives
This commit is contained in:
commit
1630de6047
14 changed files with 1217 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("...");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
105
workbench-ui/src/design/components/SearchInput/SearchInput.tsx
Normal file
105
workbench-ui/src/design/components/SearchInput/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
144
workbench-ui/src/design/components/SplitPane/SplitPane.tsx
Normal file
144
workbench-ui/src/design/components/SplitPane/SplitPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
workbench-ui/src/design/components/TabBar/TabBar.test.tsx
Normal file
99
workbench-ui/src/design/components/TabBar/TabBar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
115
workbench-ui/src/design/components/TabBar/TabBar.tsx
Normal file
115
workbench-ui/src/design/components/TabBar/TabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
74
workbench-ui/src/design/components/Timestamp/Timestamp.tsx
Normal file
74
workbench-ui/src/design/components/Timestamp/Timestamp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: “{searchValue}”
|
||||
</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"}' />
|
||||
|
|
|
|||
Loading…
Reference in a new issue