From 5b68e78ed2fc21f003fbb068b3256519661a9ba6 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 06:45:20 -0700 Subject: [PATCH] feat(workbench): six evidence primitives (Wave 1C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SplitPane, TabBar, MetadataTable, DigestBadge, Timestamp, SearchInput. All token-driven, keyboard-navigable, ARIA-accessible, dark theme, motion-respectful. No new dependencies — TabBar uses native ARIA semantics instead of @radix-ui/react-tabs. PreviewPage updated with all six components in representative states. Section 1C checked off in wave-1-evidence-spine.md. --- docs/workbench/wave-1-evidence-spine.md | 390 ++++++++++++++++++ .../DigestBadge/DigestBadge.test.tsx | 62 +++ .../components/DigestBadge/DigestBadge.tsx | 76 ++++ .../MetadataTable/MetadataTable.test.tsx | 79 ++++ .../MetadataTable/MetadataTable.tsx | 82 ++++ .../SearchInput/SearchInput.test.tsx | 92 +++++ .../components/SearchInput/SearchInput.tsx | 105 +++++ .../components/SplitPane/SplitPane.test.tsx | 98 +++++ .../design/components/SplitPane/SplitPane.tsx | 144 +++++++ .../design/components/TabBar/TabBar.test.tsx | 99 +++++ .../src/design/components/TabBar/TabBar.tsx | 115 ++++++ .../components/Timestamp/Timestamp.test.tsx | 55 +++ .../design/components/Timestamp/Timestamp.tsx | 74 ++++ workbench-ui/src/preview/PreviewPage.tsx | 127 ++++++ 14 files changed, 1598 insertions(+) create mode 100644 docs/workbench/wave-1-evidence-spine.md create mode 100644 workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx create mode 100644 workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx create mode 100644 workbench-ui/src/design/components/MetadataTable/MetadataTable.test.tsx create mode 100644 workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx create mode 100644 workbench-ui/src/design/components/SearchInput/SearchInput.test.tsx create mode 100644 workbench-ui/src/design/components/SearchInput/SearchInput.tsx create mode 100644 workbench-ui/src/design/components/SplitPane/SplitPane.test.tsx create mode 100644 workbench-ui/src/design/components/SplitPane/SplitPane.tsx create mode 100644 workbench-ui/src/design/components/TabBar/TabBar.test.tsx create mode 100644 workbench-ui/src/design/components/TabBar/TabBar.tsx create mode 100644 workbench-ui/src/design/components/Timestamp/Timestamp.test.tsx create mode 100644 workbench-ui/src/design/components/Timestamp/Timestamp.tsx diff --git a/docs/workbench/wave-1-evidence-spine.md b/docs/workbench/wave-1-evidence-spine.md new file mode 100644 index 00000000..5fbf1a02 --- /dev/null +++ b/docs/workbench/wave-1-evidence-spine.md @@ -0,0 +1,390 @@ +# Wave 1 — Evidence Spine + +Status: approved plan +Date: 2026-06-12 +Supersedes: implementation-plan.md phases W-032+ +Peer-reviewed by: Codex (architectural path selection), GPT5.5-Thinking (trace +honesty correction), Claude Opus 4.6 (original plan + synthesis) + +## Governing idea + +Evidence context is the intrinsic UI space. Every route is a projection of the +same evidence manifold. The workbench is not organized around routes (boxes on +screen); it is organized around the evidence chain: + +``` +operator intent + -> selected evidence subject + -> provenance + -> admissibility + -> replay + -> authority + -> allowed action +``` + +Chat shows the newest turn. Trace deepens it. Replay tests it. Proposals ask +whether it may alter reviewed memory. Packs/Vault/Audit reveal the substrate +that made it possible. Once the spine exists, remaining routes become inevitable +projections — not independent features. + +## Three governing principles (ADR-0160) + +1. **Audit-native, not analytics theater.** Every panel answers: what happened, + why was it allowed, what evidence exists, can it replay, who holds authority. +2. **Calm default, infinite depth.** Quiet surface by default; the deeper you + inspect, the more transparent it becomes. +3. **Replay before persuasion.** Show deterministic evidence before asking the + operator to trust anything. + +--- + +## Wave 1 deliverables + +Six pieces, one architectural idea. + +### 1A. Command Registry + Full Navigation + +- [ ] Replace hardcoded command list in `CommandPalette.tsx` with a + route-registered command registry +- [ ] Each route registers its commands on mount via a shared context/provider +- [ ] Fuzzy search across routes, recent resources (turns, proposals, artifacts), + and actions (run eval, copy hash) +- [ ] `Cmd+K` opens, type-ahead filters, arrow keys navigate, `Enter` executes, + `Esc` closes +- [ ] Recent items: last 10 visited resources (turns, proposals, artifacts) +- [ ] Test: palette finds and navigates to every route and registered command + +**Current state:** `CommandPalette.tsx` has only Chat/Proposals/Evals hardcoded. + +**Key files:** +- `workbench-ui/src/design/components/primitives/CommandPalette.tsx` +- New: `workbench-ui/src/app/commandRegistry.ts` (or context provider) + +--- + +### 1B. RightInspector as Evidence Drawer + +- [ ] Remove permanent `collapsed=true` from `Shell.tsx` +- [ ] Create shared evidence-subject context: `useEvidenceSubject()` hook + + provider in Shell +- [ ] Each route pushes its selection (turn, proposal, artifact, pack, eval + result) into the shared context +- [ ] Inspector renders the appropriate evidence projection for the selected + subject type +- [ ] Toggle via `Cmd+I` or "inspect" affordance on selectable items +- [ ] Stays open across route transitions (operator opened it deliberately) +- [ ] Collapsed by default on fresh load (calm default) +- [ ] Resizable width via drag handle +- [ ] Test: inspector opens, shows correct context for Chat selection, closes, + persists across route change + +**Current state:** `RightInspector.tsx` returns null. `Shell.tsx` hardcodes +`collapsed={true}`. + +**Key files:** +- `workbench-ui/src/app/RightInspector.tsx` +- `workbench-ui/src/app/Shell.tsx` +- New: `workbench-ui/src/app/evidenceContext.ts` + +--- + +### 1C. Minimal Evidence Primitives + +Build only the six components the spine needs. Defer everything else until a +route proves it requires the component. + +| Component | Purpose | Used by | +|---|---|---| +| `SplitPane` | Resizable horizontal/vertical split for list-detail | Trace, Proposals, Evals, Replay | +| `TabBar` | Accessible tab switching (see dependency note below) | Trace evidence sections, Inspector | +| `MetadataTable` | Key-value pair display for structured metadata | Trace, Proposals, Artifacts | +| `DigestBadge` | Copyable hash/digest with truncation + verify indicator | Trace, Replay, everywhere | +| `Timestamp` | Relative + absolute time, timezone-aware (PST/PDT) | All list views, Inspector | +| `SearchInput` | Filtered search with keyboard shortcut binding (`/`) | Command palette, list views | + +For each component: + +- [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 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 + +--- + +### 1D. Workbench Turn Evidence Journal + +**Critical correction (GPT5.5 review):** The workbench backend does NOT +currently attach a telemetry sink to chat turns. `WorkbenchApi()` constructs +with no sink, `_run_chat_turn()` creates a bare `ChatRuntime()`, and +`serialize_turn_event` redacts content by default. Raw runtime telemetry does +not contain the three surfaces needed for Trace. + +**Solution:** A Workbench Turn Evidence Journal — a local, append-only, +content-bearing record of the `ChatTurnResult` envelope already returned by +`/chat/turn`. + +This is a **read model**, not a cognitive runtime fork. It does not replace +runtime telemetry; it records the exact evidence the operator already saw. + +#### Backend + +- [ ] New module: `workbench/journal.py` +- [ ] `TurnJournal` class: append-only JSONL writer +- [ ] Each `/chat/turn` response is journaled with: + - `turn_id` (stable, sequential) + - `timestamp` (ISO-8601 UTC) + - `trace_hash` (from ChatTurnResult) + - `prompt` (content-bearing — explicit in schema) + - `surface`, `articulation_surface`, `walk_surface` (all three, kept + separate per api-contract-v1.md line 231) + - `grounding_source`, `epistemic_state`, `normative_clearance` + - `verdicts` (identity, safety, ethics) + - `refusal_emitted`, `hedge_injected` + - `proposal_candidates` + - `turn_cost_ms` + - `journal_digest` (SHA-256 of the serialized entry) +- [ ] Journal path: `workbench_data/turn_journal.jsonl` (under repo root, not + under `engine_state/` or `teaching/`) +- [ ] Content-bearing warning: journal entries contain user prompts and engine + surfaces. Document this in `workbench_data/README.md` (not as a text + header in the JSONL file — every line must be valid JSON) +- [ ] Path confinement: journal writes only to `workbench_data/` +- [ ] No journal writes to `teaching/`, `packs/`, `language_packs/data/`, or + `engine_state/`. Note: existing chat turns DO write `engine_state/` + through the normal runtime checkpoint path governed by ADR-0146/0150 — + that is existing behavior, not journal behavior. + +#### New API endpoints + +- [ ] `GET /trace/turns` — list journal entries (summary: turn_id, timestamp, + prompt excerpt, surface excerpt, trace_hash, grounding_source) +- [ ] `GET /trace/{turn_id}` — full journal entry for a turn (replaces the + current 404 behavior) +- [ ] Pagination: `?limit=50&offset=0` (default limit 50) +- [ ] Unknown turn_id returns 404 (not synthetic data) + +#### Tests + +- [ ] Append-only behavior: entries are never modified or deleted +- [ ] Stable ordering: entries are sequential by turn_id +- [ ] Prompt/content size limits respected (max 4096 chars prompt) +- [ ] Path confinement: journal cannot write outside `workbench_data/` +- [ ] No journal writes to `teaching/`, `packs/`, `language_packs/data/`; no + NEW writes to `engine_state/` beyond existing chat checkpoint behavior + (ADR-0146/0150) +- [ ] Journal digest is deterministic for identical content +- [ ] Round-trip: `/chat/turn` response -> journal -> `/trace/{turn_id}` -> + identical evidence fields + +#### Optional linkage to runtime telemetry + +If `ChatRuntime` is later configured with a `JsonlFileSink` +(`include_content=True`), the Trace route can cross-reference journal entries +with runtime telemetry events by `trace_hash`. This is additive — the journal +is the primary read model. + +#### Public interfaces and types + +Backend (Python): +- `workbench/journal.py` — `TurnJournalEntry` dataclass, `TurnJournalSummary` + dataclass, `TurnJournal` class (append, list, get) +- `workbench/schemas.py` — add `TurnJournalEntrySchema`, `TurnJournalSummarySchema` + for API serialization + +Frontend (TypeScript): +- `workbench-ui/src/types/api.ts` — add `TurnJournalEntry`, + `TurnJournalSummary` interfaces mirroring the Python shapes +- `workbench-ui/src/api/client.ts` — add `fetchTraceTurns(limit?, offset?)`, + `fetchTraceTurn(turnId)` +- `workbench-ui/src/api/queries.ts` — add `useTraceTurns()`, + `useTraceTurn(turnId)` React Query hooks +- `workbench-ui/src/app/evidenceContext.ts` — `EvidenceSubject` union type: + `{ kind: 'turn', data: TurnJournalEntry }` | `{ kind: 'proposal', ... }` | + `{ kind: 'artifact', ... }` | `{ kind: 'eval_result', ... }` | + `{ kind: 'none' }` + +All new API responses use the existing `{ ok, generated_at, data/error }` +envelope. + +--- + +### 1E. Trace Route + +- [ ] Replace `TraceRoutePlaceholder.tsx` with real Trace route +- [ ] Layout: `SplitPane` — turn timeline (left) + trace evidence panel (right) +- [ ] Turn timeline: list of journal entries with `DigestBadge` (trace_hash + thumbnail), `Timestamp`, prompt excerpt +- [ ] Trace evidence panel: `TabBar` with sections: + - **Surfaces** — all three, labeled explicitly (surface = user response, + walk_surface = telemetry evidence, articulation_surface = realizer + output). This IS the canonical proof of the api-contract-v1.md surface + separation contract. + - **Grounding** — source, epistemic state, normative clearance + - **Verdicts** — identity, safety, ethics verdicts with badge indicators + - **Metadata** — `MetadataTable` showing turn_cost_ms, checkpoint_emitted, + refusal/hedge status, proposal candidates + - **Raw** — collapsed-by-default full JSON viewer (StableJsonViewer) +- [ ] Selection pushes turn into evidence-subject context (RightInspector shows + same turn from inspector angle) +- [ ] `SearchInput` for filtering turns by prompt text or trace_hash prefix +- [ ] Empty state when journal is empty: "No turns recorded yet. Use Chat to + create evidence." +- [ ] Test: navigate to Trace, see real journal entries, select one, see evidence + panel, inspect raw JSON + +**Key design rules:** +- Versor condition (when available): green < 1e-6, red >= 1e-6 +- Walk surface labeled "telemetry/evidence" — never confused with user surface +- Trace hash always visible and copyable (replay before persuasion) +- Raw trace behind explicit expand (calm default, infinite depth) + +--- + +### 1F. Mutation Doctrine Reconciliation + +- [ ] Update `docs/workbench/implementation-plan.md` mutation section to match + reality +- [ ] Update `docs/workbench/acceptance-gates.md` to reflect admitted corridors +- [ ] Document the honest rule: + +**The mutation rule is not "no buttons ever." It is:** + +1. **Admitted corridor** — mutation only through an ADR-governed path + (math ratification via ADR-0172, chat turns via ADR-0146/0150). +2. **Explicit preconditions** — the UI shows what must be true before + mutation is allowed. +3. **Telemetry** — every mutation emits an auditable event. +4. **Replay evidence** — the operator can see replay-equivalence status + before acting. + +`RatificationCommandPanel.tsx` already implements this pattern for math +proposals. This is the template for future mutation surfaces, not an exception +to a "no mutation" rule. + +- [ ] Record what already exists: `ratify_math_proposal`, `reject`, `defer` + in `workbench/api.py` lines 112+; `RatificationCommandPanel.tsx` with + precondition gates +- [ ] No new mutation endpoints in Wave 1 beyond what exists + +--- + +## Wave 2 — Projections (after spine is live) + +Once the evidence spine exists, each remaining route becomes a projection. +These are parallelizable. + +### Packs Route + +- [ ] Backend: `GET /packs`, `GET /packs/{pack_id}` +- [ ] Frontend: pack list with verification badges, lexicon browser +- [ ] New primitive: `TreeView` (pack hierarchy) +- [ ] Pushes selected pack into evidence-subject context + +### Vault Route + +- [ ] Backend: `GET /vault/summary`, `GET /vault/entries`, + `GET /vault/entries/{entry_id}` +- [ ] Frontend: entry list with epistemic state badges, recall history +- [ ] Pushes selected entry into evidence-subject context + +### Audit Route + +- [ ] Backend: `GET /audit/events`, `GET /audit/events/{event_id}` +- [ ] Frontend: vertical event timeline, mutation boundary highlighting +- [ ] New primitive: `Timeline` +- [ ] Pushes selected event into evidence-subject context + +### Runs Route + +- [ ] Backend: `GET /runs`, `GET /runs/{session_id}` +- [ ] Frontend: session list with checkpoint badges, turn history +- [ ] Cross-links to Trace for any turn +- [ ] Pushes selected session into evidence-subject context + +### Settings Route + +- [ ] Frontend (mostly localStorage): inspector default, JSON depth, timestamp + format, API connection +- [ ] Runtime config display (read-only) +- [ ] No dangerous mutations — engine config changes require CLI + +--- + +## Wave 3 — Polish + Demo Theater + +### Existing module polish + +- [ ] Chat: multi-line composer, submission history, richer evidence strip +- [ ] Proposals: visual chain diagram, provenance links, metric deltas +- [ ] Eval Center: failure-first display, lane health overview, run progress +- [ ] Replay Theater: synchronized side-by-side diff, multi-artifact comparison + +### Demo Theater route + +- [ ] Backend: `GET /demos`, `POST /demos/{demo_id}/run`, + `GET /demos/{demo_id}/scenarios` +- [ ] Frontend: demo list, scenario results with evidence, "what this proves" / + "what this does not prove" honesty cards +- [ ] Evidence class badges: substrate-capability vs interface-contract +- [ ] "Proposer was wrong" scenarios visually highlighted + +--- + +## Keyboard map (global, shipped with Wave 1) + +| Shortcut | Action | +|---|---| +| `Cmd+K` | Command palette | +| `Cmd+I` | Toggle inspector | +| `Cmd+1`..`Cmd+0` | Navigate to route 1-10 | +| `j/k` or Up/Down | Navigate lists | +| `Enter` | Open selected item | +| `Esc` | Close drawer/palette/inspector | +| `/` | Focus search input | +| `?` | Show keyboard shortcut overlay | + +--- + +## Dependencies + +``` +Wave 1 (evidence spine) --> Wave 2 (projections, parallelizable) + --> Wave 3 (polish + demos) +``` + +Wave 1 must complete before Wave 2 work begins. Wave 2 routes are independent +of each other. Wave 3 can overlap with late Wave 2 work. + +--- + +## Explicit exclusions + +Per ADR-0160 doctrine and CLAUDE.md: + +- No multi-user auth, cloud deployment, or SaaS surface +- No animated "thinking" indicators or decorative motion +- No dashboard analytics walls +- No plugin/agent marketplace +- No corpus/pack mutation from the UI +- No mobile layout (engineering workstation only) +- No Deephaven, heavy JVM dependencies, or streaming database engines +- No framework upgrades (stays React 18 + stdlib HTTP) + +--- + +## Peer review record + +| Reviewer | Key contribution | +|---|---| +| Claude Opus 4.6 | Original 7-phase plan; synthesis into evidence spine after critique | +| Codex | Path selection: "evidence spine first" over routes-first; evidence chain as intrinsic UI manifold; mutation doctrine correction (admitted corridors, not "no buttons") | +| GPT5.5-Thinking | Trace honesty correction: workbench chat turns do not attach telemetry sink; `ChatTurnResult` content is not in runtime telemetry; solution = Workbench Turn Evidence Journal as honest read model | 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