diff --git a/docs/workbench/wave-1-evidence-spine.md b/docs/workbench/wave-1-evidence-spine.md index 2aed33dd..5c5645c5 100644 --- a/docs/workbench/wave-1-evidence-spine.md +++ b/docs/workbench/wave-1-evidence-spine.md @@ -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 diff --git a/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx b/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx new file mode 100644 index 00000000..cdc9fc2f --- /dev/null +++ b/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx @@ -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(); + const badge = screen.getByTestId("digest-badge"); + expect(badge).toHaveTextContent("sha256:4f80f7e12c7e8ca1..."); + }); + + it("uses custom algorithm prefix", () => { + render(); + expect(screen.getByTestId("digest-badge")).toHaveTextContent("blake3:"); + }); + + it("uses custom truncation length", () => { + render(); + expect(screen.getByTestId("digest-badge")).toHaveTextContent("sha256:4f80f7e1..."); + }); + + it("copies full digest to clipboard on click", () => { + render(); + fireEvent.click(screen.getByTestId("digest-badge")); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`sha256:${HASH}`); + }); + + it("has aria-label with full digest", () => { + render(); + const badge = screen.getByTestId("digest-badge"); + expect(badge.getAttribute("aria-label")).toContain(HASH); + }); + + it("shows green dot when verified is true", () => { + render(); + expect(screen.getByLabelText("Verified")).toBeInTheDocument(); + }); + + it("shows red dot when verified is false", () => { + render(); + expect(screen.getByLabelText("Not verified")).toBeInTheDocument(); + }); + + it("shows gray dot when verified is null", () => { + render(); + expect(screen.getByLabelText("Verification unknown")).toBeInTheDocument(); + }); + + it("does not show dot when verified is undefined", () => { + render(); + expect(screen.queryByLabelText("Verified")).toBeNull(); + expect(screen.queryByLabelText("Not verified")).toBeNull(); + expect(screen.queryByLabelText("Verification unknown")).toBeNull(); + }); + + it("does not truncate short digests", () => { + render(); + expect(screen.getByTestId("digest-badge")).toHaveTextContent("sha256:abc123"); + expect(screen.getByTestId("digest-badge")).not.toHaveTextContent("..."); + }); +}); diff --git a/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx b/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx new file mode 100644 index 00000000..948d4e45 --- /dev/null +++ b/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx @@ -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 ( + + ); +} + +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 ( + + ); +} diff --git a/workbench-ui/src/design/components/MetadataTable/MetadataTable.test.tsx b/workbench-ui/src/design/components/MetadataTable/MetadataTable.test.tsx new file mode 100644 index 00000000..26c7a3ed --- /dev/null +++ b/workbench-ui/src/design/components/MetadataTable/MetadataTable.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { MetadataTable } from "./MetadataTable"; + +describe("MetadataTable", () => { + it("renders key-value pairs", () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + const copyBtn = screen.getByLabelText("Copy abc123def"); + expect(copyBtn).toBeInTheDocument(); + }); + + it("copies value to clipboard when copy button clicked", () => { + render( + , + ); + 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( + , + ); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders ReactNode values", () => { + render( + Custom }]} + />, + ); + expect(screen.getByTestId("custom-badge")).toBeInTheDocument(); + }); + + it("uses definition list semantics", () => { + render( + , + ); + expect(screen.getByTestId("metadata-table").tagName).toBe("DL"); + }); +}); diff --git a/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx b/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx new file mode 100644 index 00000000..c041deff --- /dev/null +++ b/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx @@ -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 ( + + ); +} + +export function MetadataTable({ rows }: MetadataTableProps) { + return ( +
+ {rows.map((row) => ( +
+
+ {row.key} +
+
+ {row.value} + {row.copyable && typeof row.value === "string" && ( + + )} +
+
+ ))} +
+ ); +} diff --git a/workbench-ui/src/design/components/SearchInput/SearchInput.test.tsx b/workbench-ui/src/design/components/SearchInput/SearchInput.test.tsx new file mode 100644 index 00000000..bfb276af --- /dev/null +++ b/workbench-ui/src/design/components/SearchInput/SearchInput.test.tsx @@ -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 ( + + ); +} + +describe("SearchInput", () => { + it("renders an input with placeholder", () => { + render(); + expect(screen.getByPlaceholderText("Search turns...")).toBeInTheDocument(); + }); + + it("has aria-label matching placeholder", () => { + render(); + expect(screen.getByLabelText("Search turns...")).toBeInTheDocument(); + }); + + it("shows shortcut hint when empty", () => { + render(); + expect(screen.getByText("/")).toBeInTheDocument(); + }); + + it("focuses input on shortcut key press", () => { + render(); + 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( + <> + + + , + ); + 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( + {}} + />, + ); + expect(screen.getByLabelText("Clear search")).toBeInTheDocument(); + }); + + it("does not show clear button when value is empty", () => { + render(); + expect(screen.queryByLabelText("Clear search")).toBeNull(); + }); + + it("clears value on clear button click", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText("Clear search")); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("uses custom shortcut key", () => { + render( + {}} + shortcut="s" + />, + ); + expect(screen.getByText("s")).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/design/components/SearchInput/SearchInput.tsx b/workbench-ui/src/design/components/SearchInput/SearchInput.tsx new file mode 100644 index 00000000..569bc7a9 --- /dev/null +++ b/workbench-ui/src/design/components/SearchInput/SearchInput.tsx @@ -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(null); + const debounceRef = useRef>(); + + 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 ( +
+ + 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 && ( + + )} + + {shortcut} + +
+ ); +} diff --git a/workbench-ui/src/design/components/SplitPane/SplitPane.test.tsx b/workbench-ui/src/design/components/SplitPane/SplitPane.test.tsx new file mode 100644 index 00000000..9e93806a --- /dev/null +++ b/workbench-ui/src/design/components/SplitPane/SplitPane.test.tsx @@ -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( + +
Left
+
Right
+
, + ); + expect(screen.getByText("Left")).toBeInTheDocument(); + expect(screen.getByText("Right")).toBeInTheDocument(); + }); + + it("renders a separator with correct orientation for horizontal", () => { + render( + +
A
+
B
+
, + ); + const handle = screen.getByRole("separator"); + expect(handle).toHaveAttribute("aria-orientation", "vertical"); + }); + + it("renders a separator with correct orientation for vertical", () => { + render( + +
A
+
B
+
, + ); + const handle = screen.getByRole("separator"); + expect(handle).toHaveAttribute("aria-orientation", "horizontal"); + }); + + it("adjusts split via keyboard (ArrowRight for horizontal)", () => { + render( + +
A
+
B
+
, + ); + 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( + +
A
+
B
+
, + ); + 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( + +
A
+
B
+
, + ); + expect(localStorage.getItem("core-split-test-split")).toBe("40"); + }); + + it("restores split from localStorage", () => { + localStorage.setItem("core-split-restore-test", "65"); + render( + +
A
+
B
+
, + ); + const handle = screen.getByRole("separator"); + expect(handle).toHaveAttribute("aria-valuenow", "65"); + }); + + it("is focusable via tabIndex", () => { + render( + +
A
+
B
+
, + ); + const handle = screen.getByRole("separator"); + expect(handle).toHaveAttribute("tabIndex", "0"); + }); +}); diff --git a/workbench-ui/src/design/components/SplitPane/SplitPane.tsx b/workbench-ui/src/design/components/SplitPane/SplitPane.tsx new file mode 100644 index 00000000..e4ca6991 --- /dev/null +++ b/workbench-ui/src/design/components/SplitPane/SplitPane.tsx @@ -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(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 ( +
+
+ {children[0]} +
+
{ + 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" + /> +
+ {children[1]} +
+
+ ); +} diff --git a/workbench-ui/src/design/components/TabBar/TabBar.test.tsx b/workbench-ui/src/design/components/TabBar/TabBar.test.tsx new file mode 100644 index 00000000..2c5fed3b --- /dev/null +++ b/workbench-ui/src/design/components/TabBar/TabBar.test.tsx @@ -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 ( + +
Content for {active}
+
+ ); +} + +describe("TabBar", () => { + it("renders all tabs", () => { + render(); + 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(); + 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(); + const panel = screen.getByRole("tabpanel"); + expect(panel).toHaveAttribute("aria-labelledby", "tab-surfaces"); + }); + + it("changes tab on click", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByRole("tablist")).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/design/components/TabBar/TabBar.tsx b/workbench-ui/src/design/components/TabBar/TabBar.tsx new file mode 100644 index 00000000..bd14beaf --- /dev/null +++ b/workbench-ui/src/design/components/TabBar/TabBar.tsx @@ -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(null); + + const focusTab = useCallback( + (index: number) => { + const tablist = tablistRef.current; + if (!tablist) return; + const buttons = tablist.querySelectorAll('[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 ( +
+
+ {tabs.map((tab) => { + const isActive = tab.id === activeTab; + return ( + + ); + })} +
+
+ {children} +
+
+ ); +} diff --git a/workbench-ui/src/design/components/Timestamp/Timestamp.test.tsx b/workbench-ui/src/design/components/Timestamp/Timestamp.test.tsx new file mode 100644 index 00000000..5f916ef2 --- /dev/null +++ b/workbench-ui/src/design/components/Timestamp/Timestamp.test.tsx @@ -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
+
+

SplitPane

+
+ +
+ Left pane (35%) +
+
+ Right pane (65%) +
+
+
+
+ +
+ Top pane (40%) +
+
+ Bottom pane (60%) +
+
+
+
+ +
+

TabBar

+
+ +
+ Active tab: {activeTab} +
+
+
+
+ +
+

MetadataTable

+
+ +
+
+ +
+

DigestBadge

+
+ + + + +
+
+ +
+

Timestamp

+
+
+ both (default) + +
+
+ relative + +
+
+ absolute + +
+
+ yesterday + +
+
+
+ +
+

SearchInput

+
+ +
+ {searchValue && ( +

+ Filtering: “{searchValue}” +

+ )} +
+

Stable JSON Viewer