feat(workbench-ui): design system v1 scaffold (ADR-0162 Branch 1) (#295)
* feat(workbench-ui): design system v1 scaffold * fix(workbench): close R1 (GroundingSource enum coverage) + R4 (digest test) R1 — Promote GroundingSource to a typed Literal in core/epistemic_state.py so it has the same single-source-of-truth shape as ReviewState. The existing epistemic_state_for_grounding_source() function already enumerates the six labels (pack, teaching, vault, partial, oov, none); this codifies them. scripts/dump-enums.py now snapshots GroundingSource via the existing literal_values helper. workbench-ui's enumCoverage.test.ts gains a fourth assertion that the badge mapping matches the Python source 1:1. Adding a grounding-source value on the Python side without updating the badge fails the build-time test loud — same discipline as the other three enums. R4 — Add an explicit DigestBadge test to StableJsonViewer.test.tsx: asserts the badge text matches the SHA-256 prefix of the source bytes, and clicking the badge copies the FULL digest (not the truncated prefix). Recomputes the expected digest via crypto.subtle to avoid hard-coding a hex string that could drift. R2 (component-level reduced-motion enforcement), R3 (EmptyState copy-CLI affordance), and R5 (`uv run core` packaging paper cut) are deferred — R2/R3 become meaningful with W-027/W-029, R5 is a packaging-layer concern outside this PR's scope. Validation: - pnpm test: 19 passed (was 17, +1 enum coverage, +1 digest test) - pnpm build: clean - pnpm test:enum-coverage: 4 passed - core test --suite smoke -q: 67 passed
This commit is contained in:
parent
5b4dcb17ca
commit
e89463a975
42 changed files with 4738 additions and 1 deletions
|
|
@ -9,7 +9,17 @@ serialize stably into JSONL, metadata dictionaries, and test fixtures.
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, unique
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
GroundingSource = Literal["pack", "teaching", "vault", "partial", "oov", "none"]
|
||||
"""Ratified grounding-source labels.
|
||||
|
||||
Single source of truth for the values ``epistemic_state_for_grounding_source``
|
||||
maps, the cold-start-grounding lane validates, and the Workbench UI badges
|
||||
bind to (ADR-0162 §3d). Adding a value here without adding a corresponding
|
||||
badge fails the build-time enum coverage test under ``workbench-ui/``.
|
||||
"""
|
||||
|
||||
|
||||
@unique
|
||||
|
|
@ -136,6 +146,7 @@ def epistemic_state_for_grounding_source(source: str | None) -> EpistemicState:
|
|||
|
||||
__all__ = [
|
||||
"EpistemicState",
|
||||
"GroundingSource",
|
||||
"NormativeClearance",
|
||||
"clearance_from_verdicts",
|
||||
"coerce_epistemic_state",
|
||||
|
|
|
|||
61
docs/workbench/design-system.md
Normal file
61
docs/workbench/design-system.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Workbench Design System v1
|
||||
|
||||
This document records the Branch 1 substrate for ADR-0162, cross-referenced with
|
||||
[ADR-0160](../decisions/ADR-0160-core-workbench-v1.md) and
|
||||
[ADR-0161](../decisions/ADR-0161-hitl-async-queue.md).
|
||||
|
||||
The implementation lives in `workbench-ui/`. The preview page is `/preview`:
|
||||
|
||||
```bash
|
||||
cd workbench-ui && pnpm install && pnpm preview
|
||||
```
|
||||
|
||||
Branch 1 is intentionally static and read-only. It adds no backend, no API
|
||||
client, no route shell beyond `/preview`, and no runtime mutation surface.
|
||||
|
||||
## Token Substrate
|
||||
|
||||
Tokens are defined in `workbench-ui/src/design/tokens/tokens.css` and mirrored
|
||||
to typed TypeScript by `workbench-ui/scripts/generate-tokens.ts`. The build
|
||||
prehook regenerates `tokens.ts`; the token test fails if CSS and TypeScript
|
||||
diverge.
|
||||
|
||||
The theme is dark-only. Fonts are self-hosted from `workbench-ui/public/fonts/`
|
||||
and referenced through local `@font-face` rules only.
|
||||
|
||||
## Badge Tables
|
||||
|
||||
Badge components live under `workbench-ui/src/design/components/badges/`.
|
||||
Each component accepts a typed enum value, never an arbitrary string.
|
||||
|
||||
| Badge | Values | Source |
|
||||
|---|---:|---|
|
||||
| `EpistemicStateBadge` | 15 | `core/epistemic_state.py` |
|
||||
| `NormativeClearanceBadge` | 4 | `core/epistemic_state.py` |
|
||||
| `ReviewStateBadge` | 4 | `teaching/proposals.py` |
|
||||
| `GroundingSourceBadge` | 6 | ADR-0162 Branch 1 contract |
|
||||
| `TraceHashBadge` | copyable digest | ADR-0153 / ADR-0160 |
|
||||
|
||||
`scripts/dump-enums.py` performs a read-only AST walk over the Python enum
|
||||
sources. `pnpm test:enum-coverage` regenerates `workbench-ui/enum-snapshot.json`
|
||||
and asserts exact UI coverage so engine enum drift fails loudly at build/test
|
||||
time.
|
||||
|
||||
## JSON Viewer
|
||||
|
||||
`StableJsonViewer` preserves raw source spans for strings and numbers, renders
|
||||
object keys in deterministic lexicographic order, copies JSON Pointer paths,
|
||||
renders a source-byte SHA-256 badge, supports side-by-side leaf diffs, and
|
||||
refuses inline rendering above 16 MiB.
|
||||
|
||||
## Motion And Keyboard
|
||||
|
||||
All motion uses tokenized durations/easing. `prefers-reduced-motion: reduce`
|
||||
collapses tokenized durations to instant.
|
||||
|
||||
The preview page installs the Branch 1 keyboard baseline:
|
||||
|
||||
- `Cmd+K` / `Ctrl+K` opens the stub command palette.
|
||||
- `Esc` closes overlays.
|
||||
- Interactive primitives use `--color-focus-ring`.
|
||||
- Preview tab order follows DOM order.
|
||||
60
scripts/dump-enums.py
Normal file
60
scripts/dump-enums.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Dump ratified engine enum values for Workbench UI coverage tests.
|
||||
|
||||
Read-only helper: parses source with Python AST and writes JSON to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def enum_values(path: Path, class_name: str) -> list[str]:
|
||||
module = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in module.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
values: list[str] = []
|
||||
for statement in node.body:
|
||||
if (
|
||||
isinstance(statement, ast.Assign)
|
||||
and isinstance(statement.value, ast.Constant)
|
||||
and isinstance(statement.value.value, str)
|
||||
):
|
||||
values.append(statement.value.value)
|
||||
return values
|
||||
raise SystemExit(f"enum class not found: {class_name}")
|
||||
|
||||
|
||||
def literal_values(path: Path, name: str) -> list[str]:
|
||||
module = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in module.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
if not any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||
continue
|
||||
value = node.value
|
||||
if (
|
||||
isinstance(value, ast.Subscript)
|
||||
and isinstance(value.value, ast.Name)
|
||||
and value.value.id == "Literal"
|
||||
):
|
||||
items = value.slice.elts if isinstance(value.slice, ast.Tuple) else [value.slice]
|
||||
return [
|
||||
item.value
|
||||
for item in items
|
||||
if isinstance(item, ast.Constant) and isinstance(item.value, str)
|
||||
]
|
||||
raise SystemExit(f"literal alias not found: {name}")
|
||||
|
||||
|
||||
snapshot = {
|
||||
"EpistemicState": enum_values(ROOT / "core" / "epistemic_state.py", "EpistemicState"),
|
||||
"GroundingSource": literal_values(ROOT / "core" / "epistemic_state.py", "GroundingSource"),
|
||||
"NormativeClearance": enum_values(ROOT / "core" / "epistemic_state.py", "NormativeClearance"),
|
||||
"ReviewState": literal_values(ROOT / "teaching" / "proposals.py", "ReviewState"),
|
||||
}
|
||||
|
||||
print(json.dumps(snapshot, indent=2, sort_keys=True))
|
||||
7
workbench-ui/.gitignore
vendored
Normal file
7
workbench-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
scripts/*.js
|
||||
scripts/*.d.ts
|
||||
39
workbench-ui/enum-snapshot.json
Normal file
39
workbench-ui/enum-snapshot.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"EpistemicState": [
|
||||
"perceived",
|
||||
"evidenced",
|
||||
"evidenced_incomplete",
|
||||
"verified",
|
||||
"decoded",
|
||||
"decoded_unarticulated",
|
||||
"inferred",
|
||||
"unverified_possible",
|
||||
"unverified_novel",
|
||||
"contradicted",
|
||||
"ambiguous",
|
||||
"undetermined",
|
||||
"scope_boundary",
|
||||
"computationally_bounded",
|
||||
"epistemic_state_needed"
|
||||
],
|
||||
"GroundingSource": [
|
||||
"pack",
|
||||
"teaching",
|
||||
"vault",
|
||||
"partial",
|
||||
"oov",
|
||||
"none"
|
||||
],
|
||||
"NormativeClearance": [
|
||||
"cleared",
|
||||
"violated",
|
||||
"unassessable",
|
||||
"suppressed"
|
||||
],
|
||||
"ReviewState": [
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"withdrawn"
|
||||
]
|
||||
}
|
||||
12
workbench-ui/index.html
Normal file
12
workbench-ui/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CORE Workbench Preview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
workbench-ui/package.json
Normal file
45
workbench-ui/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "core-workbench-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prebuild": "pnpm generate:tokens",
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "vitest run",
|
||||
"test:enum-coverage": "pnpm enum:snapshot && vitest run src/design/components/badges/enumCoverage.test.ts",
|
||||
"generate:tokens": "tsx scripts/generate-tokens.ts",
|
||||
"enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "0.468.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tailwind-merge": "2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "18.3.17",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
"happy-dom": "15.11.7",
|
||||
"postcss": "8.4.49",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "5.4.11",
|
||||
"vitest": "2.1.8"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
3113
workbench-ui/pnpm-lock.yaml
Normal file
3113
workbench-ui/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
6
workbench-ui/postcss.config.js
Normal file
6
workbench-ui/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
8
workbench-ui/preview/README.md
Normal file
8
workbench-ui/preview/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Preview
|
||||
|
||||
The Vite route at `/preview` renders the Branch 1 design substrate.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm preview
|
||||
```
|
||||
BIN
workbench-ui/preview/preview.png
Normal file
BIN
workbench-ui/preview/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
workbench-ui/public/fonts/Inter-Regular.woff2
Normal file
BIN
workbench-ui/public/fonts/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
workbench-ui/public/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
workbench-ui/public/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
22
workbench-ui/scripts/generate-tokens.ts
Normal file
22
workbench-ui/scripts/generate-tokens.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const cssPath = resolve(here, "../src/design/tokens/tokens.css");
|
||||
const outPath = resolve(here, "../src/design/tokens/tokens.ts");
|
||||
|
||||
const css = readFileSync(cssPath, "utf8");
|
||||
const tokenMatches = [...css.matchAll(/--([a-z0-9-]+):\s*([^;]+);/g)];
|
||||
const tokens = Object.fromEntries(
|
||||
tokenMatches.map(([, name, value]) => [name, value.trim()]),
|
||||
);
|
||||
|
||||
const body = `// Generated by scripts/generate-tokens.ts. Do not edit by hand.
|
||||
export const tokens = ${JSON.stringify(tokens, null, 2)} as const;
|
||||
|
||||
export type DesignTokenName = keyof typeof tokens;
|
||||
export type DesignTokenValue = (typeof tokens)[DesignTokenName];
|
||||
`;
|
||||
|
||||
writeFileSync(outPath, body);
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StableJsonViewer } from "./StableJsonViewer";
|
||||
import { diffLeaves, leaves, parseJsonSource } from "./jsonModel";
|
||||
|
||||
async function sha256Hex(source: string): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
describe("StableJsonViewer invariants", () => {
|
||||
it("renders object keys in deterministic lexicographic order", () => {
|
||||
render(<StableJsonViewer source='{"z":1,"a":2}' />);
|
||||
const rows = within(screen.getByTestId("json-rows")).getAllByRole("button");
|
||||
expect(rows.map((row) => row.textContent)).toEqual(["/a 2", "/z 1"]);
|
||||
});
|
||||
|
||||
it("preserves source string bytes without smart quotes or entity coercion", () => {
|
||||
const source = "{\"quote\":\"a \\\"raw\\\" & <tag>\"}";
|
||||
render(<StableJsonViewer source={source} />);
|
||||
expect(screen.getByText(/"a \\"raw\\" & <tag>"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves numeric notation and int/float distinction", () => {
|
||||
render(<StableJsonViewer source='{"epsilon":1e-6,"int":1,"float":1.0}' />);
|
||||
expect(screen.getByText((_, node) => node?.textContent === "/epsilon 1e-6")).toBeInTheDocument();
|
||||
expect(screen.getByText((_, node) => node?.textContent === "/int 1")).toBeInTheDocument();
|
||||
expect(screen.getByText((_, node) => node?.textContent === "/float 1.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copy-path returns a JSON Pointer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
render(<StableJsonViewer source='{"scenes":[0,0,0,{"detail":{"object":"lens"}}]}' />);
|
||||
await user.click(screen.getByText(/\/scenes\/3\/detail\/object/));
|
||||
expect(writeText).toHaveBeenCalledWith("/scenes/3/detail/object");
|
||||
});
|
||||
|
||||
it("diff mode is side-by-side and marks only changed leaves with glyphs", () => {
|
||||
render(<StableJsonViewer source='{"same":1,"changed":1}' compareSource='{"same":1,"changed":2,"added":3}' />);
|
||||
expect(screen.getByTestId("json-diff")).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText("changed")).toHaveLength(2);
|
||||
expect(screen.getAllByLabelText("added")).toHaveLength(2);
|
||||
expect(screen.getAllByLabelText("same")).toHaveLength(2);
|
||||
|
||||
const modelDiff = diffLeaves(leaves(parseJsonSource('{"a":1}')), leaves(parseJsonSource('{"a":2}')));
|
||||
expect(modelDiff).toMatchObject([{ pointer: "/a", kind: "changed" }]);
|
||||
});
|
||||
|
||||
it("virtualizes large documents and refuses inline render above 16 MiB", () => {
|
||||
const manyLeaves = `{"items":[${Array.from({ length: 1001 }, (_, i) => i).join(",")}]}`;
|
||||
const { rerender } = render(<StableJsonViewer source={manyLeaves} />);
|
||||
expect(screen.getByTestId("virtualized-json")).toHaveTextContent("virtualized 1000 of 1001 leaves");
|
||||
|
||||
rerender(<StableJsonViewer source={`{"blob":"${"x".repeat(16 * 1024 * 1024)}"}`} />);
|
||||
expect(screen.getByText(/larger than 16 MiB/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Open in external viewer/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Copy path/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a sha256 digest badge over the source bytes and copies the full digest on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const source = '{"a":1}';
|
||||
const expectedDigest = await sha256Hex(source);
|
||||
|
||||
render(<StableJsonViewer source={source} />);
|
||||
|
||||
const digestButton = await screen.findByRole("button", { name: /sha256:/ });
|
||||
// The badge displays the truncated prefix.
|
||||
await waitFor(() => {
|
||||
expect(digestButton.textContent).toBe(`sha256:${expectedDigest.slice(0, 12)}`);
|
||||
});
|
||||
// Clicking the badge copies the FULL digest, not the truncated prefix.
|
||||
await user.click(digestButton);
|
||||
expect(writeText).toHaveBeenCalledWith(expectedDigest);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { Copy, ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "../primitives/Button";
|
||||
import { copyText, cn } from "../../lib";
|
||||
import { countLeaves, diffLeaves, leaves, parseJsonSource, type DiffKind, type JsonNode } from "./jsonModel";
|
||||
|
||||
const MAX_INLINE_BYTES = 16 * 1024 * 1024;
|
||||
const VIRTUALIZE_LEAVES = 1000;
|
||||
|
||||
function bytes(source: string) {
|
||||
return new TextEncoder().encode(source);
|
||||
}
|
||||
|
||||
async function sha256(source: string) {
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes(source));
|
||||
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function nodeRows(node: JsonNode, pointer = "", depth = 0): { pointer: string; depth: number; label: string; raw?: string }[] {
|
||||
if (node.kind === "literal") return [{ pointer, depth, label: pointer.split("/").at(-1) || "/", raw: node.raw }];
|
||||
if (node.kind === "array") {
|
||||
return node.items.flatMap((item, index) => nodeRows(item, `${pointer}/${index}`, depth + 1));
|
||||
}
|
||||
return node.entries.flatMap((entry) => nodeRows(entry.value, `${pointer}/${entry.key.replaceAll("~", "~0").replaceAll("/", "~1")}`, depth + 1));
|
||||
}
|
||||
|
||||
function DigestBadge({ source }: { source: string }) {
|
||||
const [digest, setDigest] = useState("");
|
||||
useEffect(() => {
|
||||
void sha256(source).then(setDigest);
|
||||
}, [source]);
|
||||
return (
|
||||
<button
|
||||
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 font-mono text-xs text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
onClick={() => void copyText(digest)}
|
||||
type="button"
|
||||
>
|
||||
sha256:{digest.slice(0, 12) || "pending"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeGlyph({ kind }: { kind: DiffKind }) {
|
||||
const glyph = kind === "added" ? "+" : kind === "removed" ? "-" : kind === "changed" ? "~" : " ";
|
||||
return <span aria-label={kind} className="inline-block w-4 font-mono">{glyph}</span>;
|
||||
}
|
||||
|
||||
export function StableJsonViewer({
|
||||
source,
|
||||
compareSource,
|
||||
}: {
|
||||
source: string;
|
||||
compareSource?: string;
|
||||
}) {
|
||||
const rawBytes = bytes(source).byteLength;
|
||||
const parsed = useMemo(() => (rawBytes <= MAX_INLINE_BYTES ? parseJsonSource(source) : null), [rawBytes, source]);
|
||||
const leafCount = parsed ? countLeaves(parsed) : 0;
|
||||
|
||||
if (rawBytes > MAX_INLINE_BYTES) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-raised)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<DigestBadge source={source} />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">JSON is larger than 16 MiB and was not rendered inline.</p>
|
||||
<Button onClick={() => void copyText("/")} type="button" variant="quiet">
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
Open in external viewer
|
||||
</Button>
|
||||
<Button className="ml-2" onClick={() => void copyText("/")} type="button" variant="quiet">
|
||||
<Copy size={14} aria-hidden />
|
||||
Copy path /
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (compareSource) {
|
||||
const right = parseJsonSource(compareSource);
|
||||
const rows = diffLeaves(leaves(parsed!), leaves(right));
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-raised)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<DigestBadge source={source} />
|
||||
<span className="text-xs text-[var(--color-text-muted)]">diff mode</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3" data-testid="json-diff">
|
||||
{rows.map((row) => (
|
||||
<div className="contents" key={row.pointer}>
|
||||
<button
|
||||
className={cn("rounded border px-2 py-1 text-left font-mono text-xs", row.kind !== "same" ? "border-[var(--color-state-warning-border)]" : "border-[var(--color-border-subtle)]")}
|
||||
onClick={() => void copyText(row.pointer)}
|
||||
type="button"
|
||||
>
|
||||
<ChangeGlyph kind={row.kind} />
|
||||
{row.pointer}: {row.before?.raw ?? ""}
|
||||
</button>
|
||||
<button
|
||||
className={cn("rounded border px-2 py-1 text-left font-mono text-xs", row.kind !== "same" ? "border-[var(--color-state-warning-border)]" : "border-[var(--color-border-subtle)]")}
|
||||
onClick={() => void copyText(row.pointer)}
|
||||
type="button"
|
||||
>
|
||||
<ChangeGlyph kind={row.kind} />
|
||||
{row.pointer}: {row.after?.raw ?? ""}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = nodeRows(parsed!);
|
||||
const visibleRows = leafCount > VIRTUALIZE_LEAVES ? rows.slice(0, VIRTUALIZE_LEAVES) : rows;
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-raised)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<DigestBadge source={source} />
|
||||
{leafCount > VIRTUALIZE_LEAVES ? <span data-testid="virtualized-json" className="text-xs text-[var(--color-text-muted)]">virtualized {VIRTUALIZE_LEAVES} of {leafCount} leaves</span> : null}
|
||||
</div>
|
||||
<div className="grid gap-1" data-testid="json-rows">
|
||||
{visibleRows.map((row) => (
|
||||
<button
|
||||
className="rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 text-left font-mono text-xs text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
key={row.pointer}
|
||||
onClick={() => void copyText(row.pointer)}
|
||||
style={{ paddingLeft: `${8 + row.depth * 12}px` }}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[var(--color-text-muted)]">{row.pointer || "/"}</span> {row.raw}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { StableJsonViewer } from "./StableJsonViewer";
|
||||
export { parseJsonSource, leaves, diffLeaves } from "./jsonModel";
|
||||
155
workbench-ui/src/design/components/StableJsonViewer/jsonModel.ts
Normal file
155
workbench-ui/src/design/components/StableJsonViewer/jsonModel.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
export type JsonNode =
|
||||
| { kind: "object"; entries: { key: string; keyRaw: string; value: JsonNode }[] }
|
||||
| { kind: "array"; items: JsonNode[] }
|
||||
| { kind: "literal"; raw: string; valueKind: "string" | "number" | "boolean" | "null" };
|
||||
|
||||
export type Leaf = { pointer: string; raw: string; valueKind: string };
|
||||
|
||||
class Parser {
|
||||
private index = 0;
|
||||
|
||||
constructor(private readonly source: string) {}
|
||||
|
||||
parse(): JsonNode {
|
||||
this.ws();
|
||||
const node = this.value();
|
||||
this.ws();
|
||||
if (this.index !== this.source.length) throw new Error("Unexpected trailing JSON content.");
|
||||
return node;
|
||||
}
|
||||
|
||||
private value(): JsonNode {
|
||||
this.ws();
|
||||
const ch = this.source[this.index];
|
||||
if (ch === "{") return this.object();
|
||||
if (ch === "[") return this.array();
|
||||
if (ch === '"') return { kind: "literal", raw: this.stringRaw(), valueKind: "string" };
|
||||
if (ch === "-" || /\d/.test(ch)) return { kind: "literal", raw: this.numberRaw(), valueKind: "number" };
|
||||
for (const word of ["true", "false", "null"] as const) {
|
||||
if (this.source.startsWith(word, this.index)) {
|
||||
this.index += word.length;
|
||||
return { kind: "literal", raw: word, valueKind: word === "null" ? "null" : "boolean" };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected JSON token at ${this.index}.`);
|
||||
}
|
||||
|
||||
private object(): JsonNode {
|
||||
this.index++;
|
||||
this.ws();
|
||||
const entries: { key: string; keyRaw: string; value: JsonNode }[] = [];
|
||||
if (this.source[this.index] === "}") {
|
||||
this.index++;
|
||||
return { kind: "object", entries };
|
||||
}
|
||||
while (this.index < this.source.length) {
|
||||
const keyRaw = this.stringRaw();
|
||||
const key = JSON.parse(keyRaw) as string;
|
||||
this.ws();
|
||||
this.expect(":");
|
||||
const value = this.value();
|
||||
entries.push({ key, keyRaw, value });
|
||||
this.ws();
|
||||
if (this.source[this.index] === "}") {
|
||||
this.index++;
|
||||
break;
|
||||
}
|
||||
this.expect(",");
|
||||
this.ws();
|
||||
}
|
||||
entries.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return { kind: "object", entries };
|
||||
}
|
||||
|
||||
private array(): JsonNode {
|
||||
this.index++;
|
||||
this.ws();
|
||||
const items: JsonNode[] = [];
|
||||
if (this.source[this.index] === "]") {
|
||||
this.index++;
|
||||
return { kind: "array", items };
|
||||
}
|
||||
while (this.index < this.source.length) {
|
||||
items.push(this.value());
|
||||
this.ws();
|
||||
if (this.source[this.index] === "]") {
|
||||
this.index++;
|
||||
break;
|
||||
}
|
||||
this.expect(",");
|
||||
this.ws();
|
||||
}
|
||||
return { kind: "array", items };
|
||||
}
|
||||
|
||||
private stringRaw(): string {
|
||||
const start = this.index;
|
||||
this.expect('"');
|
||||
while (this.index < this.source.length) {
|
||||
const ch = this.source[this.index++];
|
||||
if (ch === "\\") {
|
||||
this.index++;
|
||||
} else if (ch === '"') {
|
||||
return this.source.slice(start, this.index);
|
||||
}
|
||||
}
|
||||
throw new Error("Unterminated string.");
|
||||
}
|
||||
|
||||
private numberRaw(): string {
|
||||
const start = this.index;
|
||||
if (this.source[this.index] === "-") this.index++;
|
||||
while (/\d/.test(this.source[this.index] ?? "")) this.index++;
|
||||
if (this.source[this.index] === ".") {
|
||||
this.index++;
|
||||
while (/\d/.test(this.source[this.index] ?? "")) this.index++;
|
||||
}
|
||||
if ((this.source[this.index] ?? "").toLowerCase() === "e") {
|
||||
this.index++;
|
||||
if (["+", "-"].includes(this.source[this.index] ?? "")) this.index++;
|
||||
while (/\d/.test(this.source[this.index] ?? "")) this.index++;
|
||||
}
|
||||
return this.source.slice(start, this.index);
|
||||
}
|
||||
|
||||
private ws() {
|
||||
while (/\s/.test(this.source[this.index] ?? "")) this.index++;
|
||||
}
|
||||
|
||||
private expect(ch: string) {
|
||||
if (this.source[this.index] !== ch) throw new Error(`Expected ${ch} at ${this.index}.`);
|
||||
this.index++;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJsonSource(source: string): JsonNode {
|
||||
return new Parser(source).parse();
|
||||
}
|
||||
|
||||
export function pointerPart(value: string) {
|
||||
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
}
|
||||
|
||||
export function leaves(node: JsonNode, pointer = ""): Leaf[] {
|
||||
if (node.kind === "literal") return [{ pointer, raw: node.raw, valueKind: node.valueKind }];
|
||||
if (node.kind === "array") return node.items.flatMap((item, index) => leaves(item, `${pointer}/${index}`));
|
||||
return node.entries.flatMap((entry) => leaves(entry.value, `${pointer}/${pointerPart(entry.key)}`));
|
||||
}
|
||||
|
||||
export function countLeaves(node: JsonNode) {
|
||||
return leaves(node).length;
|
||||
}
|
||||
|
||||
export type DiffKind = "added" | "removed" | "changed" | "same";
|
||||
|
||||
export function diffLeaves(left: Leaf[], right: Leaf[]) {
|
||||
const l = new Map(left.map((leaf) => [leaf.pointer, leaf]));
|
||||
const r = new Map(right.map((leaf) => [leaf.pointer, leaf]));
|
||||
const pointers = [...new Set([...l.keys(), ...r.keys()])].toSorted();
|
||||
return pointers.map((pointer) => {
|
||||
const before = l.get(pointer);
|
||||
const after = r.get(pointer);
|
||||
const kind: DiffKind = before && after ? (before.raw === after.raw ? "same" : "changed") : before ? "removed" : "added";
|
||||
return { pointer, before, after, kind };
|
||||
});
|
||||
}
|
||||
67
workbench-ui/src/design/components/badges/Badge.tsx
Normal file
67
workbench-ui/src/design/components/badges/Badge.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Copy } from "lucide-react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { copyText, cn } from "../../lib";
|
||||
|
||||
export function InfoBadge({
|
||||
label,
|
||||
colorToken,
|
||||
meaning,
|
||||
adr,
|
||||
evidence,
|
||||
mono = false,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
colorToken: string;
|
||||
meaning: string;
|
||||
adr: string;
|
||||
evidence: string;
|
||||
mono?: boolean;
|
||||
onCopy?: string;
|
||||
}) {
|
||||
const style = {
|
||||
backgroundColor: `color-mix(in srgb, var(${colorToken}) 18%, transparent)`,
|
||||
borderColor: `var(${colorToken})`,
|
||||
color: `var(${colorToken})`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs font-medium transition-colors motion-standard focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{label}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={6}
|
||||
className="z-50 w-72 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-3 text-sm text-[var(--color-text-primary)] shadow-[var(--shadow-overlay)]"
|
||||
>
|
||||
<div className="font-semibold">{label}</div>
|
||||
<p className="my-2 text-[var(--color-text-secondary)]">{meaning}</p>
|
||||
<dl className="m-0 grid gap-1 text-xs">
|
||||
<dt className="text-[var(--color-text-muted)]">Pinned by</dt>
|
||||
<dd className="m-0">{adr}</dd>
|
||||
<dt className="text-[var(--color-text-muted)]">Evidence example</dt>
|
||||
<dd className="m-0">{evidence}</dd>
|
||||
</dl>
|
||||
{onCopy ? (
|
||||
<button
|
||||
className="mt-3 inline-flex items-center gap-1 rounded border border-[var(--color-border-subtle)] px-2 py-1 text-xs focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
onClick={() => void copyText(onCopy)}
|
||||
type="button"
|
||||
>
|
||||
<Copy size={12} aria-hidden />
|
||||
Copy
|
||||
</button>
|
||||
) : null}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import snapshot from "../../../../enum-snapshot.json";
|
||||
import {
|
||||
epistemicStateMeta,
|
||||
groundingSourceMeta,
|
||||
normativeClearanceMeta,
|
||||
reviewStateMeta,
|
||||
} from "./mappings";
|
||||
|
||||
function expectExactCoverage(name: string, engineValues: string[], uiValues: string[]) {
|
||||
expect(
|
||||
uiValues.toSorted(),
|
||||
`${name} badge mapping must exactly match engine enum values`,
|
||||
).toEqual(engineValues.toSorted());
|
||||
expect(new Set(uiValues).size, `${name} badge mapping has duplicate values`).toBe(uiValues.length);
|
||||
}
|
||||
|
||||
describe("build-time enum coverage", () => {
|
||||
it("tracks every ratified EpistemicState value exactly once", () => {
|
||||
expectExactCoverage(
|
||||
"EpistemicState",
|
||||
snapshot.EpistemicState,
|
||||
Object.keys(epistemicStateMeta),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks every ratified NormativeClearance value exactly once", () => {
|
||||
expectExactCoverage(
|
||||
"NormativeClearance",
|
||||
snapshot.NormativeClearance,
|
||||
Object.keys(normativeClearanceMeta),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks every ratified ReviewState value exactly once", () => {
|
||||
expectExactCoverage("ReviewState", snapshot.ReviewState, Object.keys(reviewStateMeta));
|
||||
});
|
||||
|
||||
it("tracks every ratified GroundingSource value exactly once", () => {
|
||||
expectExactCoverage(
|
||||
"GroundingSource",
|
||||
snapshot.GroundingSource,
|
||||
Object.keys(groundingSourceMeta),
|
||||
);
|
||||
});
|
||||
});
|
||||
47
workbench-ui/src/design/components/badges/index.tsx
Normal file
47
workbench-ui/src/design/components/badges/index.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
epistemicStateMeta,
|
||||
groundingSourceMeta,
|
||||
normativeClearanceMeta,
|
||||
reviewStateMeta,
|
||||
} from "./mappings";
|
||||
import { InfoBadge } from "./Badge";
|
||||
import { EpistemicState, GroundingSource, NormativeClearance, ReviewState } from "./types";
|
||||
|
||||
export { EpistemicState, GroundingSource, NormativeClearance, ReviewState };
|
||||
|
||||
export function EpistemicStateBadge({ value }: { value: EpistemicState }) {
|
||||
return <InfoBadge {...epistemicStateMeta[value]} />;
|
||||
}
|
||||
|
||||
export function NormativeClearanceBadge({ value }: { value: NormativeClearance }) {
|
||||
return <InfoBadge {...normativeClearanceMeta[value]} />;
|
||||
}
|
||||
|
||||
export function ReviewStateBadge({ value }: { value: ReviewState }) {
|
||||
return <InfoBadge {...reviewStateMeta[value]} />;
|
||||
}
|
||||
|
||||
export function GroundingSourceBadge({ value }: { value: GroundingSource }) {
|
||||
return <InfoBadge {...groundingSourceMeta[value]} />;
|
||||
}
|
||||
|
||||
export function TraceHashBadge({
|
||||
value,
|
||||
truncate = 12,
|
||||
}: {
|
||||
value: string;
|
||||
truncate?: number;
|
||||
}) {
|
||||
const label = value.slice(0, truncate);
|
||||
return (
|
||||
<InfoBadge
|
||||
label={label}
|
||||
colorToken="--color-text-mono"
|
||||
meaning="Deterministic trace hash. Click copy for the full digest."
|
||||
adr="ADR-0153 / ADR-0160 / ADR-0162"
|
||||
evidence="TurnEvent.trace_hash is present."
|
||||
mono
|
||||
onCopy={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
workbench-ui/src/design/components/badges/mappings.ts
Normal file
48
workbench-ui/src/design/components/badges/mappings.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
EpistemicState,
|
||||
GroundingSource,
|
||||
NormativeClearance,
|
||||
ReviewState,
|
||||
type BadgeMeta,
|
||||
} from "./types";
|
||||
|
||||
export const epistemicStateMeta = {
|
||||
[EpistemicState.PERCEIVED]: { label: "Perceived", colorToken: "--color-state-perceived", meaning: "Observed at the surface but not yet independently evidenced.", adr: "ADR-0142 / ADR-0162", evidence: "A trace carries raw input evidence." },
|
||||
[EpistemicState.EVIDENCED]: { label: "Evidenced", colorToken: "--color-state-evidenced", meaning: "Supported by explicit evidence.", adr: "ADR-0142 / ADR-0162", evidence: "A proposition cites a supporting artifact." },
|
||||
[EpistemicState.EVIDENCED_INCOMPLETE]: { label: "Evidenced incomplete", colorToken: "--color-state-evidenced-muted", meaning: "Some evidence exists, but coverage is partial.", adr: "ADR-0142 / ADR-0162", evidence: "Grounding source is partial." },
|
||||
[EpistemicState.VERIFIED]: { label: "Verified", colorToken: "--color-state-verified", meaning: "Checked against an authoritative deterministic lane.", adr: "ADR-0142 / ADR-0162", evidence: "Replay hash matches original." },
|
||||
[EpistemicState.DECODED]: { label: "Decoded", colorToken: "--color-state-decoded", meaning: "Mapped through ratified semantic structure.", adr: "ADR-0142 / ADR-0162", evidence: "Grounding source is pack, teaching, or vault." },
|
||||
[EpistemicState.DECODED_UNARTICULATED]: { label: "Decoded unarticulated", colorToken: "--color-state-decoded-muted", meaning: "Structured internally but not surfaced.", adr: "ADR-0142 / ADR-0162", evidence: "Graph node exists without realized sentence." },
|
||||
[EpistemicState.INFERRED]: { label: "Inferred", colorToken: "--color-state-inferred", meaning: "Derived from available structure.", adr: "ADR-0142 / ADR-0162", evidence: "Planner derives a relation from linked propositions." },
|
||||
[EpistemicState.UNVERIFIED_POSSIBLE]: { label: "Unverified possible", colorToken: "--color-state-unverified", meaning: "Plausible but not verified.", adr: "ADR-0142 / ADR-0162", evidence: "Candidate has no ratified support yet." },
|
||||
[EpistemicState.UNVERIFIED_NOVEL]: { label: "Unverified novel", colorToken: "--color-state-unverified-warm", meaning: "New or OOV content without ratified grounding.", adr: "ADR-0142 / ADR-0162", evidence: "Grounding source is oov." },
|
||||
[EpistemicState.CONTRADICTED]: { label: "Contradicted", colorToken: "--color-state-contradicted", meaning: "Evidence conflicts with the proposition.", adr: "ADR-0142 / ADR-0162", evidence: "A boundary or claim rejects the assertion." },
|
||||
[EpistemicState.AMBIGUOUS]: { label: "Ambiguous", colorToken: "--color-state-ambiguous", meaning: "Multiple readings remain live.", adr: "ADR-0142 / ADR-0162", evidence: "Parser retains competing interpretations." },
|
||||
[EpistemicState.UNDETERMINED]: { label: "Undetermined", colorToken: "--color-state-undetermined", meaning: "No stronger epistemic state is justified.", adr: "ADR-0142 / ADR-0162", evidence: "No grounding source is present." },
|
||||
[EpistemicState.SCOPE_BOUNDARY]: { label: "Scope boundary", colorToken: "--color-state-scope", meaning: "The claim is outside the active scope.", adr: "ADR-0142 / ADR-0162", evidence: "Runtime declines to classify beyond scope." },
|
||||
[EpistemicState.COMPUTATIONALLY_BOUNDED]: { label: "Computationally bounded", colorToken: "--color-state-bounded", meaning: "The answer is limited by bounded computation.", adr: "ADR-0142 / ADR-0162", evidence: "Eval lane reports a bounded operator." },
|
||||
[EpistemicState.EPISTEMIC_STATE_NEEDED]: { label: "State needed", colorToken: "--color-state-needed", meaning: "A missing state must be assigned before trust display.", adr: "ADR-0142 / ADR-0162", evidence: "Unknown grounding source reached UI mapping." },
|
||||
} satisfies BadgeMeta<EpistemicState>;
|
||||
|
||||
export const normativeClearanceMeta = {
|
||||
[NormativeClearance.CLEARED]: { label: "Cleared", colorToken: "--color-clearance-cleared", meaning: "Runtime-checkable normative gates passed.", adr: "ADR-0142 / ADR-0162", evidence: "Safety and ethics verdicts upheld." },
|
||||
[NormativeClearance.VIOLATED]: { label: "Violated", colorToken: "--color-clearance-violated", meaning: "A runtime-checkable boundary failed.", adr: "ADR-0142 / ADR-0162", evidence: "Boundary id appears in normative detail." },
|
||||
[NormativeClearance.UNASSESSABLE]: { label: "Unassessable", colorToken: "--color-clearance-unassessable", meaning: "No positive clearance can be asserted.", adr: "ADR-0142 / ADR-0162", evidence: "No runtime-checkable verdict exists." },
|
||||
[NormativeClearance.SUPPRESSED]: { label: "Suppressed", colorToken: "--color-clearance-suppressed", meaning: "A refusal surface replaced unsafe output.", adr: "ADR-0142 / ADR-0162", evidence: "refusal_emitted is true." },
|
||||
} satisfies BadgeMeta<NormativeClearance>;
|
||||
|
||||
export const reviewStateMeta = {
|
||||
[ReviewState.PENDING]: { label: "Pending", colorToken: "--color-review-pending", meaning: "Proposal awaits operator review.", adr: "ADR-0057 / ADR-0161 / ADR-0162", evidence: "No terminal transition exists." },
|
||||
[ReviewState.ACCEPTED]: { label: "Accepted", colorToken: "--color-review-accepted", meaning: "Operator ratified the proposal.", adr: "ADR-0057 / ADR-0161 / ADR-0162", evidence: "transition.to is accepted." },
|
||||
[ReviewState.REJECTED]: { label: "Rejected", colorToken: "--color-review-rejected", meaning: "Operator rejected the proposal.", adr: "ADR-0057 / ADR-0161 / ADR-0162", evidence: "transition.to is rejected." },
|
||||
[ReviewState.WITHDRAWN]: { label: "Withdrawn", colorToken: "--color-review-withdrawn", meaning: "Proposal was withdrawn without ratification.", adr: "ADR-0057 / ADR-0161 / ADR-0162", evidence: "transition.to is withdrawn." },
|
||||
} satisfies BadgeMeta<ReviewState>;
|
||||
|
||||
export const groundingSourceMeta = {
|
||||
[GroundingSource.PACK]: { label: "Pack", colorToken: "--color-grounding-pack", meaning: "Grounded in a curated semantic pack.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is pack." },
|
||||
[GroundingSource.TEACHING]: { label: "Teaching", colorToken: "--color-grounding-teaching", meaning: "Grounded in reviewed teaching memory.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is teaching." },
|
||||
[GroundingSource.VAULT]: { label: "Vault", colorToken: "--color-grounding-vault", meaning: "Grounded in deterministic vault recall.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is vault." },
|
||||
[GroundingSource.PARTIAL]: { label: "Partial", colorToken: "--color-grounding-partial", meaning: "Only partial grounding was available.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is partial." },
|
||||
[GroundingSource.OOV]: { label: "OOV", colorToken: "--color-grounding-oov", meaning: "Out-of-vocabulary grounding was encountered.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is oov." },
|
||||
[GroundingSource.NONE]: { label: "None", colorToken: "--color-grounding-none", meaning: "No grounding source was present.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is none." },
|
||||
} satisfies BadgeMeta<GroundingSource>;
|
||||
51
workbench-ui/src/design/components/badges/types.ts
Normal file
51
workbench-ui/src/design/components/badges/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export enum EpistemicState {
|
||||
PERCEIVED = "perceived",
|
||||
EVIDENCED = "evidenced",
|
||||
EVIDENCED_INCOMPLETE = "evidenced_incomplete",
|
||||
VERIFIED = "verified",
|
||||
DECODED = "decoded",
|
||||
DECODED_UNARTICULATED = "decoded_unarticulated",
|
||||
INFERRED = "inferred",
|
||||
UNVERIFIED_POSSIBLE = "unverified_possible",
|
||||
UNVERIFIED_NOVEL = "unverified_novel",
|
||||
CONTRADICTED = "contradicted",
|
||||
AMBIGUOUS = "ambiguous",
|
||||
UNDETERMINED = "undetermined",
|
||||
SCOPE_BOUNDARY = "scope_boundary",
|
||||
COMPUTATIONALLY_BOUNDED = "computationally_bounded",
|
||||
EPISTEMIC_STATE_NEEDED = "epistemic_state_needed",
|
||||
}
|
||||
|
||||
export enum NormativeClearance {
|
||||
CLEARED = "cleared",
|
||||
VIOLATED = "violated",
|
||||
UNASSESSABLE = "unassessable",
|
||||
SUPPRESSED = "suppressed",
|
||||
}
|
||||
|
||||
export enum ReviewState {
|
||||
PENDING = "pending",
|
||||
ACCEPTED = "accepted",
|
||||
REJECTED = "rejected",
|
||||
WITHDRAWN = "withdrawn",
|
||||
}
|
||||
|
||||
export enum GroundingSource {
|
||||
PACK = "pack",
|
||||
TEACHING = "teaching",
|
||||
VAULT = "vault",
|
||||
PARTIAL = "partial",
|
||||
OOV = "oov",
|
||||
NONE = "none",
|
||||
}
|
||||
|
||||
export type BadgeMeta<T extends string> = Record<
|
||||
T,
|
||||
{
|
||||
label: string;
|
||||
colorToken: string;
|
||||
meaning: string;
|
||||
adr: string;
|
||||
evidence: string;
|
||||
}
|
||||
>;
|
||||
30
workbench-ui/src/design/components/primitives/Button.tsx
Normal file
30
workbench-ui/src/design/components/primitives/Button.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium transition-colors motion-standard focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)] disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
"border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-raised)]",
|
||||
quiet:
|
||||
"border-[var(--color-border-subtle)] bg-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, asChild, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { Search } from "lucide-react";
|
||||
import { EmptyState } from "../states/EmptyState";
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/55" data-testid="overlay" />
|
||||
<Dialog.Content className="fixed left-1/2 top-[18vh] w-[min(560px,calc(100vw-32px))] -translate-x-1/2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-4 shadow-[var(--shadow-overlay)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]">
|
||||
<Dialog.Title className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Search size={16} aria-hidden />
|
||||
Command Palette
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="sr-only">
|
||||
Empty command palette stub for the Branch 1 keyboard contract.
|
||||
</Dialog.Description>
|
||||
<EmptyState
|
||||
statement="No commands are registered in Branch 1."
|
||||
nextAction="W-027 will add command content behind this keybinding."
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
18
workbench-ui/src/design/components/states/EmptyState.tsx
Normal file
18
workbench-ui/src/design/components/states/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Button } from "../primitives/Button";
|
||||
|
||||
export function EmptyState({
|
||||
statement,
|
||||
nextAction,
|
||||
}: {
|
||||
statement: string;
|
||||
nextAction: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<p className="m-0 text-sm text-[var(--color-text-primary)]">{statement}</p>
|
||||
<Button className="mt-3" variant="quiet" type="button">
|
||||
{nextAction}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
workbench-ui/src/design/components/states/ErrorState.tsx
Normal file
34
workbench-ui/src/design/components/states/ErrorState.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export function ErrorState({
|
||||
whatFailed,
|
||||
mutationStatus,
|
||||
reproducer,
|
||||
retrySafety,
|
||||
}: {
|
||||
whatFailed: string;
|
||||
mutationStatus: string;
|
||||
reproducer: string;
|
||||
retrySafety: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-state-danger-border)] bg-[var(--color-state-danger-bg)] p-4 text-sm text-[var(--color-state-danger-text)]">
|
||||
<dl className="m-0 grid gap-2">
|
||||
<div>
|
||||
<dt className="font-semibold">What failed</dt>
|
||||
<dd className="m-0">{whatFailed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">Mutation status</dt>
|
||||
<dd className="m-0">{mutationStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">Reproducer</dt>
|
||||
<dd className="m-0 font-mono text-xs">{reproducer}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">Retry safety</dt>
|
||||
<dd className="m-0">{retrySafety}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
16
workbench-ui/src/design/components/states/LoadingState.tsx
Normal file
16
workbench-ui/src/design/components/states/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function LoadingState({ label }: { label: string }) {
|
||||
if (label.trim().toLowerCase() === "thinking...") {
|
||||
throw new Error('LoadingState label must be specific; "Thinking..." is forbidden.');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">{label}</p>
|
||||
<div
|
||||
aria-hidden
|
||||
className="mt-3 h-3 w-full rounded bg-[var(--color-surface-overlay)] after:block after:h-3 after:w-1/3 after:rounded after:bg-[var(--color-border-strong)] after:content-['']"
|
||||
data-shimmer-cycles="2"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
workbench-ui/src/design/components/states/states.test.tsx
Normal file
37
workbench-ui/src/design/components/states/states.test.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
import { LoadingState } from "./LoadingState";
|
||||
|
||||
describe("state components", () => {
|
||||
it("requires empty-state statement and next action", () => {
|
||||
render(<EmptyState statement="No trace selected." nextAction="Select a trace." />);
|
||||
expect(screen.getByText("No trace selected.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Select a trace." })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every error-state contract field", () => {
|
||||
render(
|
||||
<ErrorState
|
||||
whatFailed="Preview fixture failed to parse."
|
||||
mutationStatus="No mutation attempted."
|
||||
reproducer="pnpm test"
|
||||
retrySafety="Retry is read-only."
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("What failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mutation status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reproducer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retry safety")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires a specific loading label and caps shimmer cycles", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
expect(() => render(<LoadingState label="Thinking..." />)).toThrow();
|
||||
consoleError.mockRestore();
|
||||
render(<LoadingState label="Loading trace fixture." />);
|
||||
expect(screen.getByText("Loading trace fixture.")).toBeInTheDocument();
|
||||
expect(document.querySelector("[data-shimmer-cycles='2']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
10
workbench-ui/src/design/lib.ts
Normal file
10
workbench-ui/src/design/lib.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export async function copyText(value: string) {
|
||||
await navigator.clipboard?.writeText(value);
|
||||
}
|
||||
146
workbench-ui/src/design/tokens/tokens.css
Normal file
146
workbench-ui/src/design/tokens/tokens.css
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("/fonts/Inter-Regular.woff2") format("woff2");
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root,
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--color-surface-base: #0d1117;
|
||||
--color-surface-raised: #131923;
|
||||
--color-surface-overlay: #191f2a;
|
||||
--color-surface-inset: #070a0f;
|
||||
--color-border-subtle: #252d3a;
|
||||
--color-border-strong: #3a4556;
|
||||
--color-text-primary: #eef2f7;
|
||||
--color-text-secondary: #aeb8c7;
|
||||
--color-text-mono: #d8e2ef;
|
||||
--color-text-muted: #788395;
|
||||
--color-focus-ring: #7dd3fc;
|
||||
--color-link: #8bd3ff;
|
||||
|
||||
--color-state-decoded: #4fc3b4;
|
||||
--color-state-decoded-muted: #3d8f86;
|
||||
--color-state-verified: #67d391;
|
||||
--color-state-evidenced: #38bfa7;
|
||||
--color-state-evidenced-muted: #2f8175;
|
||||
--color-state-inferred: #8d8cff;
|
||||
--color-state-unverified: #8aa0b8;
|
||||
--color-state-unverified-warm: #d39b4f;
|
||||
--color-state-perceived: #5ec8f2;
|
||||
--color-state-contradicted: #f07178;
|
||||
--color-state-ambiguous: #b18cff;
|
||||
--color-state-undetermined: #77808f;
|
||||
--color-state-scope: #64748b;
|
||||
--color-state-bounded: #e59f48;
|
||||
--color-state-needed: #f3c45b;
|
||||
|
||||
--color-clearance-cleared: #74d99f;
|
||||
--color-clearance-violated: #f25f6f;
|
||||
--color-clearance-unassessable: #7f8998;
|
||||
--color-clearance-suppressed: #bd5b68;
|
||||
|
||||
--color-review-pending: #e8b94e;
|
||||
--color-review-accepted: #57c989;
|
||||
--color-review-rejected: #cf6470;
|
||||
--color-review-withdrawn: #858d9b;
|
||||
|
||||
--color-grounding-teaching: #61d394;
|
||||
--color-grounding-pack: #42c7b5;
|
||||
--color-grounding-vault: #7f95ff;
|
||||
--color-grounding-partial: #6faea4;
|
||||
--color-grounding-oov: #e9a94f;
|
||||
--color-grounding-none: #6f7b8c;
|
||||
|
||||
--color-state-info-bg: #10253a;
|
||||
--color-state-info-border: #23648e;
|
||||
--color-state-info-text: #bde8ff;
|
||||
--color-state-success-bg: #10291e;
|
||||
--color-state-success-border: #2d7a55;
|
||||
--color-state-success-text: #b9f3d3;
|
||||
--color-state-warning-bg: #31240c;
|
||||
--color-state-warning-border: #9c6a1b;
|
||||
--color-state-warning-text: #ffd89a;
|
||||
--color-state-danger-bg: #351419;
|
||||
--color-state-danger-border: #9f3848;
|
||||
--color-state-danger-text: #ffc3ca;
|
||||
--color-state-neutral-bg: #1b212c;
|
||||
--color-state-neutral-border: #465164;
|
||||
--color-state-neutral-text: #d4dbe7;
|
||||
--color-state-purple-bg: #241a34;
|
||||
--color-state-purple-border: #7655a2;
|
||||
--color-state-purple-text: #dec8ff;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-1: var(--radius-sm);
|
||||
--radius-2: var(--radius-md);
|
||||
--radius-3: var(--radius-lg);
|
||||
--shadow-panel: 0 10px 35px rgb(0 0 0 / 0.28);
|
||||
--shadow-floating: 0 18px 60px rgb(0 0 0 / 0.45);
|
||||
--shadow-overlay: var(--shadow-floating);
|
||||
|
||||
--font-size-display: 1.75rem;
|
||||
--font-size-h1: 1.375rem;
|
||||
--font-size-h2: 1rem;
|
||||
--font-size-body: 0.875rem;
|
||||
--font-size-meta: 0.75rem;
|
||||
--font-size-mono: 0.75rem;
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 650;
|
||||
--line-height-tight: 1.2;
|
||||
--line-height-base: 1.5;
|
||||
--text-xs: var(--font-size-meta);
|
||||
--text-sm: var(--font-size-body);
|
||||
--text-md: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: var(--font-size-h1);
|
||||
--line-tight: var(--line-height-tight);
|
||||
--line-normal: var(--line-height-base);
|
||||
|
||||
--motion-instant: 0ms;
|
||||
--motion-fast: 120ms;
|
||||
--motion-base: 180ms;
|
||||
--motion-slow: 1200ms;
|
||||
--motion-ease-out: cubic-bezier(0.2, 0, 0, 1);
|
||||
--motion-ease-spring: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-duration-instant: var(--motion-instant);
|
||||
--motion-duration-fast: var(--motion-fast);
|
||||
--motion-duration-standard: var(--motion-base);
|
||||
--motion-duration-slow: var(--motion-slow);
|
||||
--motion-ease-standard: var(--motion-ease-out);
|
||||
--motion-ease-emphasized: var(--motion-ease-spring);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: var(--motion-duration-instant) !important;
|
||||
transition-duration: var(--motion-duration-instant) !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
37
workbench-ui/src/design/tokens/tokens.test.ts
Normal file
37
workbench-ui/src/design/tokens/tokens.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tokens } from "./tokens";
|
||||
|
||||
function parseCssTokens() {
|
||||
const css = readFileSync(resolve(process.cwd(), "src/design/tokens/tokens.css"), "utf8");
|
||||
return Object.fromEntries(
|
||||
[...css.matchAll(/--([a-z0-9-]+):\s*([^;]+);/g)].map(([, name, value]) => [
|
||||
name,
|
||||
value.trim(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("design tokens", () => {
|
||||
it("keeps tokens.css and generated tokens.ts synchronized", () => {
|
||||
expect(tokens).toEqual(parseCssTokens());
|
||||
});
|
||||
|
||||
it("collapses tokenized motion under reduced motion", () => {
|
||||
const reduced = true;
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: reduced && query.includes("prefers-reduced-motion"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
expect(window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true);
|
||||
expect(tokens["motion-instant"]).toBe("0ms");
|
||||
});
|
||||
});
|
||||
115
workbench-ui/src/design/tokens/tokens.ts
Normal file
115
workbench-ui/src/design/tokens/tokens.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Generated by scripts/generate-tokens.ts. Do not edit by hand.
|
||||
export const tokens = {
|
||||
"font-sans": "\"Inter\", ui-sans-serif, system-ui, sans-serif",
|
||||
"font-mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
|
||||
"color-surface-base": "#0d1117",
|
||||
"color-surface-raised": "#131923",
|
||||
"color-surface-overlay": "#191f2a",
|
||||
"color-surface-inset": "#070a0f",
|
||||
"color-border-subtle": "#252d3a",
|
||||
"color-border-strong": "#3a4556",
|
||||
"color-text-primary": "#eef2f7",
|
||||
"color-text-secondary": "#aeb8c7",
|
||||
"color-text-mono": "#d8e2ef",
|
||||
"color-text-muted": "#788395",
|
||||
"color-focus-ring": "#7dd3fc",
|
||||
"color-link": "#8bd3ff",
|
||||
"color-state-decoded": "#4fc3b4",
|
||||
"color-state-decoded-muted": "#3d8f86",
|
||||
"color-state-verified": "#67d391",
|
||||
"color-state-evidenced": "#38bfa7",
|
||||
"color-state-evidenced-muted": "#2f8175",
|
||||
"color-state-inferred": "#8d8cff",
|
||||
"color-state-unverified": "#8aa0b8",
|
||||
"color-state-unverified-warm": "#d39b4f",
|
||||
"color-state-perceived": "#5ec8f2",
|
||||
"color-state-contradicted": "#f07178",
|
||||
"color-state-ambiguous": "#b18cff",
|
||||
"color-state-undetermined": "#77808f",
|
||||
"color-state-scope": "#64748b",
|
||||
"color-state-bounded": "#e59f48",
|
||||
"color-state-needed": "#f3c45b",
|
||||
"color-clearance-cleared": "#74d99f",
|
||||
"color-clearance-violated": "#f25f6f",
|
||||
"color-clearance-unassessable": "#7f8998",
|
||||
"color-clearance-suppressed": "#bd5b68",
|
||||
"color-review-pending": "#e8b94e",
|
||||
"color-review-accepted": "#57c989",
|
||||
"color-review-rejected": "#cf6470",
|
||||
"color-review-withdrawn": "#858d9b",
|
||||
"color-grounding-teaching": "#61d394",
|
||||
"color-grounding-pack": "#42c7b5",
|
||||
"color-grounding-vault": "#7f95ff",
|
||||
"color-grounding-partial": "#6faea4",
|
||||
"color-grounding-oov": "#e9a94f",
|
||||
"color-grounding-none": "#6f7b8c",
|
||||
"color-state-info-bg": "#10253a",
|
||||
"color-state-info-border": "#23648e",
|
||||
"color-state-info-text": "#bde8ff",
|
||||
"color-state-success-bg": "#10291e",
|
||||
"color-state-success-border": "#2d7a55",
|
||||
"color-state-success-text": "#b9f3d3",
|
||||
"color-state-warning-bg": "#31240c",
|
||||
"color-state-warning-border": "#9c6a1b",
|
||||
"color-state-warning-text": "#ffd89a",
|
||||
"color-state-danger-bg": "#351419",
|
||||
"color-state-danger-border": "#9f3848",
|
||||
"color-state-danger-text": "#ffc3ca",
|
||||
"color-state-neutral-bg": "#1b212c",
|
||||
"color-state-neutral-border": "#465164",
|
||||
"color-state-neutral-text": "#d4dbe7",
|
||||
"color-state-purple-bg": "#241a34",
|
||||
"color-state-purple-border": "#7655a2",
|
||||
"color-state-purple-text": "#dec8ff",
|
||||
"space-1": "0.25rem",
|
||||
"space-2": "0.5rem",
|
||||
"space-3": "0.75rem",
|
||||
"space-4": "1rem",
|
||||
"space-5": "1.25rem",
|
||||
"space-6": "1.5rem",
|
||||
"space-8": "2rem",
|
||||
"space-10": "2.5rem",
|
||||
"space-12": "3rem",
|
||||
"radius-sm": "0.25rem",
|
||||
"radius-md": "0.375rem",
|
||||
"radius-lg": "0.5rem",
|
||||
"radius-1": "var(--radius-sm)",
|
||||
"radius-2": "var(--radius-md)",
|
||||
"radius-3": "var(--radius-lg)",
|
||||
"shadow-panel": "0 10px 35px rgb(0 0 0 / 0.28)",
|
||||
"shadow-floating": "0 18px 60px rgb(0 0 0 / 0.45)",
|
||||
"shadow-overlay": "var(--shadow-floating)",
|
||||
"font-size-display": "1.75rem",
|
||||
"font-size-h1": "1.375rem",
|
||||
"font-size-h2": "1rem",
|
||||
"font-size-body": "0.875rem",
|
||||
"font-size-meta": "0.75rem",
|
||||
"font-size-mono": "0.75rem",
|
||||
"font-weight-regular": "400",
|
||||
"font-weight-medium": "500",
|
||||
"font-weight-semibold": "650",
|
||||
"line-height-tight": "1.2",
|
||||
"line-height-base": "1.5",
|
||||
"text-xs": "var(--font-size-meta)",
|
||||
"text-sm": "var(--font-size-body)",
|
||||
"text-md": "1rem",
|
||||
"text-lg": "1.125rem",
|
||||
"text-xl": "var(--font-size-h1)",
|
||||
"line-tight": "var(--line-height-tight)",
|
||||
"line-normal": "var(--line-height-base)",
|
||||
"motion-instant": "0ms",
|
||||
"motion-fast": "120ms",
|
||||
"motion-base": "180ms",
|
||||
"motion-slow": "1200ms",
|
||||
"motion-ease-out": "cubic-bezier(0.2, 0, 0, 1)",
|
||||
"motion-ease-spring": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"motion-duration-instant": "var(--motion-instant)",
|
||||
"motion-duration-fast": "var(--motion-fast)",
|
||||
"motion-duration-standard": "var(--motion-base)",
|
||||
"motion-duration-slow": "var(--motion-slow)",
|
||||
"motion-ease-standard": "var(--motion-ease-out)",
|
||||
"motion-ease-emphasized": "var(--motion-ease-spring)"
|
||||
} as const;
|
||||
|
||||
export type DesignTokenName = keyof typeof tokens;
|
||||
export type DesignTokenValue = (typeof tokens)[DesignTokenName];
|
||||
35
workbench-ui/src/index.css
Normal file
35
workbench-ui/src/index.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
@import "./design/tokens/tokens.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: var(--color-surface-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
line-height: var(--line-normal);
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
[tabindex]:not([tabindex="-1"]) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.motion-standard {
|
||||
transition-duration: var(--motion-duration-standard);
|
||||
transition-timing-function: var(--motion-ease-standard);
|
||||
}
|
||||
14
workbench-ui/src/main.tsx
Normal file
14
workbench-ui/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { PreviewPage } from "./preview/PreviewPage";
|
||||
|
||||
function App() {
|
||||
return window.location.pathname === "/preview" ? <PreviewPage /> : <PreviewPage />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
35
workbench-ui/src/preview/PreviewPage.test.tsx
Normal file
35
workbench-ui/src/preview/PreviewPage.test.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PreviewPage } from "./PreviewPage";
|
||||
|
||||
describe("/preview", () => {
|
||||
it("renders every primitive with network unavailable", () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("network blocked"))));
|
||||
render(<PreviewPage />);
|
||||
expect(screen.getByRole("heading", { name: "CORE Workbench Design System v1" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Primitives" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Badges" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "States" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Stable JSON Viewer" })).toBeInTheDocument();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens CommandPalette with mod+k and closes overlays with Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PreviewPage />);
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
expect(screen.getByRole("dialog", { name: "Command Palette" })).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog", { name: "Command Palette" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps tab order in DOM order for preview actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PreviewPage />);
|
||||
await user.tab();
|
||||
expect(screen.getByRole("button", { name: "Primary action" })).toHaveFocus();
|
||||
await user.tab();
|
||||
expect(screen.getByRole("button", { name: "Quiet action" })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
85
workbench-ui/src/preview/PreviewPage.tsx
Normal file
85
workbench-ui/src/preview/PreviewPage.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { StableJsonViewer } from "../design/components/StableJsonViewer";
|
||||
import {
|
||||
EpistemicState,
|
||||
EpistemicStateBadge,
|
||||
GroundingSource,
|
||||
GroundingSourceBadge,
|
||||
NormativeClearance,
|
||||
NormativeClearanceBadge,
|
||||
ReviewState,
|
||||
ReviewStateBadge,
|
||||
TraceHashBadge,
|
||||
} from "../design/components/badges";
|
||||
import { Button } from "../design/components/primitives/Button";
|
||||
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";
|
||||
|
||||
export function PreviewPage() {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
setPaletteOpen(true);
|
||||
}
|
||||
if (event.key === "Escape") setPaletteOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto grid max-w-6xl gap-8 px-5 py-8">
|
||||
<header>
|
||||
<h1 className="m-0 text-xl font-semibold">CORE Workbench Design System v1</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-[var(--color-text-secondary)]">
|
||||
Static Branch 1 substrate preview. No backend, no API client, no runtime mutation surface.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="primitive-heading" className="grid gap-3">
|
||||
<h2 id="primitive-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">Primitives</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button">Primary action</Button>
|
||||
<Button type="button" variant="quiet">Quiet action</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="badge-heading" className="grid gap-3">
|
||||
<h2 id="badge-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">Badges</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(EpistemicState).map((value) => <EpistemicStateBadge key={value} value={value} />)}
|
||||
{Object.values(NormativeClearance).map((value) => <NormativeClearanceBadge key={value} value={value} />)}
|
||||
{Object.values(ReviewState).map((value) => <ReviewStateBadge key={value} value={value} />)}
|
||||
{Object.values(GroundingSource).map((value) => <GroundingSourceBadge key={value} value={value} />)}
|
||||
<TraceHashBadge value="4f80f7e12c7e8ca1f1a277f8ccecf2846f08bb9d8f22354e6d3f30eb7fb34c80" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="state-heading" className="grid gap-3">
|
||||
<h2 id="state-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">States</h2>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<EmptyState statement="No artifact selected." nextAction="Select an artifact." />
|
||||
<ErrorState
|
||||
whatFailed="Preview artifact failed validation."
|
||||
mutationStatus="No mutation attempted."
|
||||
reproducer="cd workbench-ui && pnpm test"
|
||||
retrySafety="Retry reads static fixtures only."
|
||||
/>
|
||||
<LoadingState label="Loading preview fixture." />
|
||||
</div>
|
||||
</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"}' />
|
||||
</section>
|
||||
|
||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
8
workbench-ui/src/test/setup.ts
Normal file
8
workbench-ui/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
15
workbench-ui/tailwind.config.ts
Normal file
15
workbench-ui/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "ui-monospace", "SFMono-Regular", "monospace"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
22
workbench-ui/tsconfig.json
Normal file
22
workbench-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"types": ["vitest/globals"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "scripts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
13
workbench-ui/tsconfig.node.json
Normal file
13
workbench-ui/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "scripts/*.ts"]
|
||||
}
|
||||
11
workbench-ui/vite.config.ts
Normal file
11
workbench-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue