Merge pull request #713 from AssetOverflow/feat/wb-r1-design-mastery

feat(workbench): design mastery pass — chain rail, typography, doctrine-as-tests (Wave R R1)
This commit is contained in:
Shay 2026-06-12 13:37:50 -07:00 committed by GitHub
commit 41755c001f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 783 additions and 47 deletions

49
scripts/dump-schemas.py Normal file
View file

@ -0,0 +1,49 @@
"""Dump workbench API dataclass field names for UI schema-drift tests.
Read-only helper: parses workbench/schemas.py with Python AST and writes
JSON to stdout {class_name: [own_field, ...]}. Inherited fields belong
to the parent class entry (the TS mirrors use `extends` the same way).
Same pattern as scripts/dump-enums.py (ADR-0162 enum coverage).
"""
from __future__ import annotations
import ast
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCHEMAS = ROOT / "workbench" / "schemas.py"
def is_dataclass_decorated(node: ast.ClassDef) -> bool:
for dec in node.decorator_list:
target = dec.func if isinstance(dec, ast.Call) else dec
if isinstance(target, ast.Name) and target.id == "dataclass":
return True
if isinstance(target, ast.Attribute) and target.attr == "dataclass":
return True
return False
def own_fields(node: ast.ClassDef) -> list[str]:
fields: list[str] = []
for statement in node.body:
if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
fields.append(statement.target.id)
return fields
def main() -> None:
module = ast.parse(SCHEMAS.read_text(encoding="utf-8"))
out: dict[str, list[str]] = {}
for node in module.body:
if isinstance(node, ast.ClassDef) and is_dataclass_decorated(node):
out[node.name] = own_fields(node)
if not out:
raise SystemExit("no dataclasses found in workbench/schemas.py")
print(json.dumps(out, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

View file

@ -12,7 +12,8 @@
"test:e2e": "playwright test",
"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"
"enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json",
"schema:snapshot": "cd .. && uv run python scripts/dump-schemas.py > workbench-ui/schema-snapshot.json"
},
"dependencies": {
"@radix-ui/react-dialog": "1.1.15",

View file

@ -0,0 +1,223 @@
{
"ArtifactDetail": [
"content_type",
"content"
],
"ArtifactRef": [
"artifact_id",
"kind",
"path",
"digest",
"created_at"
],
"AuditEvent": [
"event_id",
"source",
"source_path",
"timestamp",
"event_type",
"mutation_boundary",
"summary",
"ref_id",
"payload_digest",
"payload"
],
"ChatTurnResult": [
"prompt",
"surface",
"articulation_surface",
"walk_surface",
"grounding_source",
"epistemic_state",
"normative_clearance",
"normative_detail",
"trace_hash",
"refusal_emitted",
"hedge_injected",
"mutation_mode",
"identity_verdict",
"safety_verdict",
"ethics_verdict",
"proposal_candidates",
"turn_cost_ms",
"checkpoint_emitted",
"turn_id"
],
"EvalLaneSummary": [
"lane",
"versions",
"read_only",
"description"
],
"EvalRunResult": [
"lane",
"version",
"split",
"passed",
"metrics",
"cases",
"source_digest"
],
"MathProposalDetail": [
"wrong_zero_assertion",
"proposed_change_payload",
"reasoning_trace_id",
"reasoning_trace_steps",
"evidence_hashes",
"handler_name",
"suggested_ratify_cli"
],
"MathProposalSummary": [
"proposal_id",
"domain",
"shape_category",
"proposed_change_kind",
"structural_commonality",
"evidence_count",
"replay_equivalence_hash"
],
"MathRatifyResult": [
"proposal_id",
"change_kind",
"handler_name",
"routing_status",
"message",
"suggested_cli",
"applied",
"target_path",
"evidence_hash"
],
"MathReasoningStep": [
"step_index",
"step_kind",
"claim",
"justification",
"input_pointers",
"output_payload"
],
"PackDetail": [
"manifest_digest",
"manifest"
],
"PackSummary": [
"pack_id",
"source",
"manifest_path",
"version",
"language",
"modality",
"determinism_class",
"checksum",
"checksums"
],
"ProposalDetail": [
"proposed_chain",
"replay_evidence",
"source",
"evidence",
"artifact_refs",
"suggested_cli"
],
"ProposalRef": [
"candidate_id",
"source_kind"
],
"ProposalSummary": [
"proposal_id",
"state",
"source_kind",
"replay_equivalent",
"created_at",
"downstream_effect"
],
"ReplayComparison": [
"artifact_id",
"original_hash",
"replay_hash",
"equivalent",
"divergences"
],
"ReplayDivergence": [
"path",
"original",
"replay",
"severity"
],
"RunDetail": [
"turns",
"manifest"
],
"RunSummary": [
"session_id",
"source",
"turn_count",
"started_at",
"updated_at",
"checkpoint_present",
"checkpoint_revision",
"artifact_refs",
"evidence_gap"
],
"RunTurnRef": [
"turn_id",
"trace_hash",
"timestamp",
"trace_path",
"surface_excerpt"
],
"RuntimeStatus": [
"backend",
"git_revision",
"engine_state_present",
"checkpoint_revision",
"revision_warning",
"active_session_id",
"mutation_mode"
],
"TurnJournalEntrySchema": [
"turn_id",
"timestamp",
"trace_hash",
"prompt",
"surface",
"articulation_surface",
"walk_surface",
"grounding_source",
"epistemic_state",
"normative_clearance",
"verdicts",
"refusal_emitted",
"hedge_injected",
"proposal_candidates",
"turn_cost_ms",
"checkpoint_emitted",
"journal_digest"
],
"TurnJournalSummarySchema": [
"turn_id",
"timestamp",
"prompt_excerpt",
"surface_excerpt",
"trace_hash",
"grounding_source"
],
"TurnVerdict": [
"outcome",
"runtime_detail"
],
"VaultEntry": [
"entry_index",
"epistemic_status",
"epistemic_state",
"metadata",
"versor_digest"
],
"VaultSummary": [
"source_path",
"entry_count",
"store_count",
"reproject_interval",
"max_entries",
"persisted"
]
}

View file

@ -0,0 +1,107 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { EvidenceChainRail, deriveStages } from "./EvidenceChainRail";
import type { EvidenceSubject } from "./evidenceContext";
import type { ChatTurnResult, ProposalDetail } from "../types/api";
const FULL_TURN: ChatTurnResult = {
prompt: "What is alpha?",
surface: "alpha causes beta",
articulation_surface: "alpha causes beta",
walk_surface: "alpha -> beta",
grounding_source: "teaching",
epistemic_state: "decoded",
normative_clearance: "cleared",
normative_detail: "",
trace_hash: "sha256:abc123",
refusal_emitted: false,
hedge_injected: false,
mutation_mode: "runtime_turn",
identity_verdict: null,
safety_verdict: null,
ethics_verdict: null,
proposal_candidates: [],
turn_cost_ms: 17,
checkpoint_emitted: true,
};
function turnSubject(data: ChatTurnResult | undefined): EvidenceSubject {
return { kind: "turn", turnId: 1, data } as EvidenceSubject;
}
function statusOf(subject: EvidenceSubject, stageId: string): string {
const stages = deriveStages(subject);
const found = stages?.find((s) => s.id === stageId);
if (!found) throw new Error(`stage not derived: ${stageId}`);
return found.status;
}
describe("EvidenceChainRail honesty contract", () => {
it("a fully-evidenced turn lights exactly the applicable stages", () => {
const subject = turnSubject(FULL_TURN);
expect(statusOf(subject, "intent")).toBe("lit");
expect(statusOf(subject, "subject")).toBe("lit");
expect(statusOf(subject, "provenance")).toBe("lit");
expect(statusOf(subject, "admissibility")).toBe("lit");
expect(statusOf(subject, "replay")).toBe("lit");
expect(statusOf(subject, "authority")).toBe("lit");
expect(statusOf(subject, "action")).toBe("dim");
});
it("MEANINGFULLY FAILS: removing a single field hollows exactly its stage", () => {
// This is the test that breaks if anyone makes the rail infer status
// instead of deriving it from the named field.
const noHash = turnSubject({ ...FULL_TURN, trace_hash: "" });
expect(statusOf(noHash, "replay")).toBe("hollow");
expect(statusOf(noHash, "provenance")).toBe("lit");
const noGrounding = turnSubject({ ...FULL_TURN, grounding_source: "" as ChatTurnResult["grounding_source"] });
expect(statusOf(noGrounding, "provenance")).toBe("hollow");
expect(statusOf(noGrounding, "replay")).toBe("lit");
});
it("identity-only subject (detail not loaded): applicable stages are hollow, never lit", () => {
const subject = turnSubject(undefined);
for (const id of ["intent", "provenance", "admissibility", "replay", "authority"]) {
expect(statusOf(subject, id)).toBe("hollow");
}
expect(statusOf(subject, "subject")).toBe("lit");
});
it("proposal: replay_equivalent=false is still recorded evidence (lit), null is hollow", () => {
const base: ProposalDetail = {
proposal_id: "p-1",
state: "pending",
source_kind: "contemplation",
replay_equivalent: false,
created_at: null,
downstream_effect: "unknown",
proposed_chain: [],
provenance: null,
suggested_cli: null,
} as unknown as ProposalDetail;
const recorded: EvidenceSubject = { kind: "proposal", proposalId: "p-1", data: base } as EvidenceSubject;
expect(statusOf(recorded, "replay")).toBe("lit");
const unrecorded: EvidenceSubject = {
kind: "proposal",
proposalId: "p-1",
data: { ...base, replay_equivalent: null },
} as EvidenceSubject;
expect(statusOf(unrecorded, "replay")).toBe("hollow");
});
it("renders no rail for kind=none", () => {
const { container } = render(<EvidenceChainRail subject={{ kind: "none" }} />);
expect(container.querySelector('[data-testid="evidence-chain-rail"]')).toBeNull();
});
it("renders all seven stages with status data attributes", () => {
const { container } = render(<EvidenceChainRail subject={turnSubject(FULL_TURN)} />);
const items = container.querySelectorAll("[data-stage]");
expect(items.length).toBe(7);
expect(container.querySelectorAll('[data-status="lit"]').length).toBe(6);
expect(container.querySelectorAll('[data-status="dim"]').length).toBe(1);
});
});

View file

@ -0,0 +1,186 @@
import type { CSSProperties } from "react";
import type { EvidenceSubject } from "./evidenceContext";
/**
* EvidenceChainRail the spine's seven stages rendered for the selected
* subject (Wave R brief R1):
*
* intent -> subject -> provenance -> admissibility -> replay -> authority -> action
*
* HONESTY CONTRACT: a stage's status derives ONLY from fields the subject
* actually carries. `lit` = the named field holds evidence. `hollow` = the
* stage applies to this subject kind but no evidence is recorded/loaded.
* `dim` = the stage does not apply to this kind. Nothing is ever inferred:
* a recorded trace hash lights "replay" as "trace hash recorded" it does
* NOT claim replay was verified.
*/
export type StageStatus = "lit" | "hollow" | "dim";
export interface RailStage {
id: string;
label: string;
status: StageStatus;
/** What the status derives from — shown in the tooltip, audit-honest. */
derivation: string;
}
const STAGE_IDS = [
"intent",
"subject",
"provenance",
"admissibility",
"replay",
"authority",
"action",
] as const;
function stage(
id: (typeof STAGE_IDS)[number],
status: StageStatus,
derivation: string,
): RailStage {
return { id, label: id, status, derivation };
}
function evidenceOf(value: unknown): "lit" | "hollow" {
if (value === null || value === undefined || value === "") return "hollow";
return "lit";
}
/** Pure derivation — exported for the meaningfully-fail tests. */
export function deriveStages(subject: EvidenceSubject): RailStage[] | null {
switch (subject.kind) {
case "none":
return null;
case "turn": {
const d = subject.data;
return [
stage("intent", d ? evidenceOf(d.prompt) : "hollow", "prompt"),
stage("subject", "lit", "selected turn"),
stage("provenance", d ? evidenceOf(d.grounding_source) : "hollow", "grounding_source"),
stage(
"admissibility",
d ? evidenceOf(d.epistemic_state) : "hollow",
"epistemic_state + normative_clearance",
),
stage("replay", d ? evidenceOf(d.trace_hash) : "hollow", "trace_hash recorded (not a verification claim)"),
stage("authority", d ? evidenceOf(d.mutation_mode) : "hollow", "mutation_mode"),
stage("action", "dim", "not applicable to a completed turn"),
];
}
case "proposal": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable — proposals originate in contemplation"),
stage("subject", "lit", "selected proposal"),
stage("provenance", d ? evidenceOf(d.source_kind) : "hollow", "source_kind"),
stage(
"admissibility",
d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow",
"replay_equivalent recorded",
),
stage(
"replay",
d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow",
"replay_equivalent value (true and false are both evidence)",
),
stage("authority", d ? evidenceOf(d.state) : "hollow", "review state (ADR-0057 machine)"),
stage("action", d ? evidenceOf(d.suggested_cli) : "hollow", "suggested_cli"),
];
}
case "artifact": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable to stored artifacts"),
stage("subject", "lit", "selected artifact"),
stage("provenance", d ? evidenceOf(d.path) : "hollow", "path"),
stage("admissibility", "dim", "not applicable to stored artifacts"),
stage("replay", d ? evidenceOf(d.digest) : "hollow", "digest"),
stage("authority", "dim", "not applicable to stored artifacts"),
stage("action", "dim", "not applicable to stored artifacts"),
];
}
case "eval_result": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable to eval runs"),
stage("subject", "lit", "selected eval result"),
stage("provenance", d ? evidenceOf(d.lane) : "hollow", "lane + version"),
stage("admissibility", d ? evidenceOf(d.split) : "hollow", "split (lane discipline)"),
stage("replay", d ? evidenceOf(d.source_digest) : "hollow", "source_digest"),
stage("authority", "dim", "not applicable to read-only lanes"),
stage("action", d ? (d.passed === null ? "hollow" : "lit") : "hollow", "pass/fail recorded"),
];
}
}
}
const STATUS_STYLE: Record<StageStatus, { dot: CSSProperties; text: string }> = {
lit: {
dot: {
background: "var(--color-state-evidenced)",
border: "1px solid var(--color-state-evidenced)",
},
text: "text-[var(--color-text-secondary)]",
},
hollow: {
dot: {
background: "transparent",
border: "1px solid var(--color-border-strong)",
},
text: "text-[var(--color-text-muted)]",
},
dim: {
dot: {
background: "var(--color-border-subtle)",
border: "1px solid var(--color-border-subtle)",
opacity: 0.5,
},
text: "text-[var(--color-text-muted)] opacity-60",
},
};
const STATUS_WORD: Record<StageStatus, string> = {
lit: "evidence present",
hollow: "not recorded",
dim: "not applicable",
};
export function EvidenceChainRail({ subject }: { subject: EvidenceSubject }) {
const stages = deriveStages(subject);
if (!stages) return null;
return (
<ol
className="m-0 flex list-none flex-wrap items-center gap-x-1 gap-y-1 border-b p-0 pb-2"
style={{ borderColor: "var(--color-border-subtle)" }}
aria-label="Evidence chain"
data-testid="evidence-chain-rail"
>
{stages.map((s, i) => (
<li
key={s.id}
className="flex items-center gap-1"
title={`${s.label}: ${STATUS_WORD[s.status]}${s.derivation}`}
data-stage={s.id}
data-status={s.status}
>
<span
aria-hidden
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={STATUS_STYLE[s.status].dot}
/>
<span className={`text-[10px] ${STATUS_STYLE[s.status].text}`}>
{s.label}
</span>
{i < stages.length - 1 && (
<span aria-hidden className="text-[10px] text-[var(--color-text-muted)]">
</span>
)}
</li>
))}
</ol>
);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext";
import { EvidenceChainRail } from "./EvidenceChainRail";
import { MetadataTable } from "../design/components/MetadataTable/MetadataTable";
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
import { Timestamp } from "../design/components/Timestamp/Timestamp";
@ -161,6 +162,15 @@ function NoneInspector() {
function InspectorContent() {
const { subject } = useEvidenceSubject();
return (
<div className="grid content-start gap-2">
<EvidenceChainRail subject={subject} />
<InspectorProjection subject={subject} />
</div>
);
}
function InspectorProjection({ subject }: { subject: EvidenceSubject }) {
switch (subject.kind) {
case "turn":
return <TurnInspector subject={subject} />;

View file

@ -1,17 +0,0 @@
import { Copy } from "lucide-react";
import { copyText } from "../../design/lib";
export function CopyableHash({ value, label = "trace_hash" }: { value: string; label?: string }) {
const short = value.length > 18 ? `${value.slice(0, 18)}...` : value;
return (
<button
type="button"
onClick={() => void copyText(value)}
className="inline-flex items-center gap-1 rounded 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)]"
aria-label={`${label}: ${value}`}
>
<Copy size={12} aria-hidden />
{label}:{short}
</button>
);
}

View file

@ -10,7 +10,7 @@ import {
} from "../../design/components/badges";
import type { KeyboardEvent, ReactNode } from "react";
import type { ChatTurnResult } from "../../types/api";
import { CopyableHash } from "./CopyableHash";
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
export type TraceFocus =
| "metadata"
@ -98,7 +98,7 @@ export function EvidenceStrip({
) : null}
{result.trace_hash ? (
<span onClick={() => onOpen("trace")}>
<CopyableHash value={result.trace_hash} />
<DigestBadge digest={result.trace_hash.replace("sha256:", "")} />
</span>
) : null}
</div>

View file

@ -6,7 +6,7 @@ import { Button } from "../../design/components/primitives/Button";
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { copyText } from "../../design/lib";
import type { ChatTurnResult, TurnVerdict } from "../../types/api";
import { CopyableHash } from "./CopyableHash";
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
import type { TraceFocus } from "./EvidenceStrip";
function Panel({
@ -159,7 +159,7 @@ export function TraceDrawer({
)}
</Panel>
<Panel id="trace" title="Trace hash + replay">
{result.trace_hash ? <CopyableHash value={result.trace_hash} /> : <p className="m-0 text-sm">No trace hash recorded.</p>}
{result.trace_hash ? <DigestBadge digest={result.trace_hash.replace("sha256:", "")} /> : <p className="m-0 text-sm">No trace hash recorded.</p>}
<button
type="button"
className="mt-2 rounded border border-[var(--color-border-subtle)] px-2 py-1 font-mono text-xs text-[var(--color-text-secondary)]"

View file

@ -26,7 +26,7 @@ export function EvalLaneCard({
onClick={onSelect}
className={`w-full text-left p-3 rounded-lg border transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${
isSelected
? "border-[var(--color-border-strong)] bg-[var(--color-surface-raised)] shadow-[var(--shadow-panel)]"
? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] shadow-[var(--shadow-panel)]"
: "border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] hover:bg-[var(--color-surface-raised)]"
}`}
type="button"

View file

@ -84,7 +84,7 @@ export function EvalMetricGrid({
<div className="text-[10px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wider truncate" title={key}>
{key.replaceAll("_", " ")}
</div>
<div className={`mt-1 font-mono text-sm font-semibold text-[var(--color-text-primary)] ${isObj ? "whitespace-pre overflow-x-auto text-[10px] bg-[var(--color-surface-inset)] p-1.5 rounded" : ""}`}>
<div className={`mt-1 font-mono tabular-nums text-sm font-semibold text-[var(--color-text-primary)] ${isObj ? "whitespace-pre overflow-x-auto text-[10px] bg-[var(--color-surface-inset)] p-1.5 rounded" : ""}`}>
{formatted}
{unit && <span className="ml-1 text-xs text-[var(--color-text-muted)] font-sans font-normal">{unit}</span>}
</div>

View file

@ -5,6 +5,7 @@ import { useCommandRegistry } from "../commandRegistry";
import { subjectToUrl } from "../evidenceAddress";
import { useEvalLanes, useEvalRun } from "../../api/queries";
import { EvalLaneCard } from "./EvalLaneCard";
import { Panel } from "../../design/components/Panel/Panel";
import { EvalRunButton } from "./EvalRunButton";
import { EvalMetricGrid } from "./EvalMetricGrid";
import { EvalFailureViewer } from "./EvalFailureViewer";
@ -118,8 +119,7 @@ export function EvalsRoute() {
return (
<div className="grid h-full grid-cols-1 gap-4 md:grid-cols-[18rem_1fr]" data-testid="evals-route">
{/* Left Pane: Lane List */}
<div className="flex flex-col gap-3 border-r border-[var(--color-border-subtle)] pr-4 overflow-y-auto">
<h2 className="text-md font-semibold text-[var(--color-text-primary)]">Eval Lanes</h2>
<Panel title="Eval Lanes">
<div className="flex flex-col gap-2">
{lanes && lanes.length > 0 ? (
lanes.map((lane) => (
@ -137,7 +137,7 @@ export function EvalsRoute() {
/>
)}
</div>
</div>
</Panel>
{/* Right Pane: Results / Form */}
<div className="flex flex-col gap-4 overflow-y-auto pl-2">

View file

@ -53,11 +53,13 @@ export function ProposalTable({
role="button"
tabIndex={0}
className={`grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] transition-all ${
selected ? "bg-[var(--color-surface-inset)]" : ""
selected ? "bg-[var(--color-selected-bg)]" : ""
} ${
focused
? "bg-[var(--color-surface-inset)] border-l-2 border-[var(--color-focus-ring)] pl-[10px]"
: "border-l-2 border-transparent pl-[10px]"
selected
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
: focused
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
: "border-l-2 border-l-transparent pl-[10px]"
}`}
key={proposal.proposal_id}
onClick={() => onSelect(proposal.proposal_id)}

View file

@ -16,6 +16,7 @@ import { ProposalChainViewer } from "./ProposalChainViewer";
import { ProposalProvenanceViewer } from "./ProposalProvenanceViewer";
import { ProposalSummaryCard } from "./ProposalSummaryCard";
import { ProposalTable } from "./ProposalTable";
import { Panel } from "../../design/components/Panel/Panel";
import { ReplayEvidenceCard } from "./ReplayEvidenceCard";
import { RatificationCommandPanel } from "./RatificationCommandPanel";
@ -244,12 +245,10 @@ export function ProposalsRoute() {
return (
<div className="grid h-full min-h-0 gap-4 xl:grid-cols-[minmax(34rem,0.95fr)_minmax(32rem,1.05fr)]">
<section className="grid min-h-0 content-start gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4">
<h1 className="m-0 text-base font-semibold text-[var(--color-text-primary)]">Proposal Queue</h1>
{/* Domain Selector Tabs */}
<div className="flex bg-[var(--color-surface-inset)] p-0.5 rounded border border-[var(--color-border-subtle)]">
<Panel
title="Proposal Queue"
toolbar={
<div className="flex bg-[var(--color-surface-inset)] p-0.5 rounded border border-[var(--color-border-subtle)]">
<button
onClick={() => changeDomain("math")}
className={`px-3 py-1 rounded text-xs font-semibold transition-all ${
@ -270,9 +269,10 @@ export function ProposalsRoute() {
>
Cognition Queue
</button>
</div>
</div>
}
>
<div className="grid content-start gap-3">
{domain === "cognition" && (
<div className="flex flex-wrap gap-2" role="group" aria-label="Proposal state filter">
{filters.map((state) => (
@ -288,7 +288,6 @@ export function ProposalsRoute() {
))}
</div>
)}
</div>
<SearchInput
placeholder="Filter by proposal id or source kind"
@ -313,7 +312,8 @@ export function ProposalsRoute() {
onSelect={selectProposal}
/>
)}
</section>
</div>
</Panel>
<section className="min-h-0 overflow-y-auto pr-1">
{!selectedProposalId ? (

View file

@ -106,7 +106,7 @@ export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListPr
className={cn(
"w-full rounded px-2 py-1.5 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]",
isSelected
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-focus-ring)] pl-1.5"
? "bg-[var(--color-selected-bg)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-selected-border)] pl-1.5"
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-primary)]"
)}
aria-current={isSelected ? "true" : undefined}

View file

@ -7,7 +7,7 @@ describe("DigestBadge", () => {
it("renders truncated digest with algorithm prefix", () => {
render(<DigestBadge digest={HASH} />);
const badge = screen.getByTestId("digest-badge");
expect(badge).toHaveTextContent("sha256:4f80f7e12c7e8ca1...");
expect(badge).toHaveTextContent("sha256:4f80f7e12c7e...");
});
it("uses custom algorithm prefix", () => {

View file

@ -37,7 +37,8 @@ export function DigestBadge({
digest,
algorithm = "sha256",
verified,
truncate = 16,
// Wave R hash display standard: 12 visible chars + copy, everywhere.
truncate = 12,
}: DigestBadgeProps) {
const [copied, setCopied] = useState(false);
const scheduleReset = useManagedTimeout();

View file

@ -66,7 +66,7 @@ export function MetadataTable({ rows }: MetadataTableProps) {
{row.key}
</dt>
<dd
className="m-0 flex items-center text-sm text-[var(--color-text-primary)]"
className="m-0 flex items-center text-sm text-[var(--color-text-primary)] tabular-nums"
style={{
fontSize: "var(--text-sm)",
fontFamily: row.mono ? "var(--font-mono)" : undefined,

View file

@ -26,7 +26,7 @@ export function Panel({ title, toolbar, children }: PanelProps) {
style={{ borderColor: "var(--color-border-subtle)" }}
>
<h2
className="m-0 text-sm font-semibold"
className="m-0 text-sm font-semibold [text-wrap:balance]"
style={{ color: "var(--color-text-primary)" }}
>
{title}

View file

@ -18,7 +18,20 @@ export function EmptyState({
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>
{/* Deterministic monochrome glyph: an empty evidence slot. Static
inline SVG same bytes every render, no asset fetch. */}
<svg
aria-hidden
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="mb-2 text-[var(--color-text-muted)]"
>
<rect x="3" y="3" width="14" height="14" rx="3" stroke="currentColor" strokeDasharray="3 3" />
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
</svg>
<p className="m-0 text-sm text-[var(--color-text-primary)] [text-wrap:balance]">{statement}</p>
{typeof nextAction === "string" ? (
<Button className="mt-3" variant="quiet" type="button">
{nextAction}

View file

@ -0,0 +1,55 @@
// @vitest-environment node
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Doctrine-as-test (ADR-0162 §1): token names are semantic; palette values
* live ONLY in tokens.css (and its generated TS mirror). A hex or rgb()/
* hsl() literal anywhere else is palette leakage and fails here.
*/
const SRC = join(__dirname, "..", "..");
const ALLOWED = new Set([
"design/tokens/tokens.css",
"design/tokens/tokens.ts", // generated mirror of tokens.css
// The scanner's own pattern definitions and the drift gate's PR-number
// comments ("#712" parses as 3-digit hex) are not palette usage:
"design/doctrine/hexScan.test.ts",
"design/doctrine/schemaDrift.test.ts",
]);
// Hex colors only: 3/4/6/8 hex digits after '#', not longer (so content
// hashes like "4f80f7e12c7e" without '#' never match).
const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
const FUNC_COLOR = /\b(?:rgb|rgba|hsl|hsla)\(/g;
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full, out);
} else if (/\.(tsx?|css)$/.test(entry)) {
out.push(full);
}
}
return out;
}
describe("doctrine: no palette literals outside tokens", () => {
it("src/** carries no hex or rgb()/hsl() color literals", () => {
const violations: string[] = [];
for (const file of walk(SRC)) {
const rel = relative(SRC, file).replaceAll("\\", "/");
if (ALLOWED.has(rel)) continue;
const text = readFileSync(file, "utf-8");
for (const match of text.matchAll(HEX_COLOR)) {
violations.push(`${rel}: ${match[0]}`);
}
for (const match of text.matchAll(FUNC_COLOR)) {
violations.push(`${rel}: ${match[0]}…)`);
}
}
expect(violations, violations.join("\n")).toEqual([]);
});
});

View file

@ -0,0 +1,89 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Doctrine-as-test (Wave R brief R1): the UI cannot silently diverge from
* the engine's API schemas. `scripts/dump-schemas.py` AST-walks
* workbench/schemas.py into schema-snapshot.json (committed, same pattern
* as enum-snapshot.json); this test asserts every dataclass field appears
* in the matching TS interface in src/types/api.ts.
*
* Python class -> TS interface mapping strips a trailing "Schema"
* (TurnJournalEntrySchema -> TurnJournalEntry).
*
* NOT_YET_MIRRORED is the explicit debt list: backend schemas shipped ahead
* of their routes (R2-B, PR #712). Each R2 route brief shrinks it. A class
* that gains a TS interface while still listed here FAILS the list can
* only shrink.
*/
const NOT_YET_MIRRORED = new Set([
// Turn journal (R0 brief 1) — TS mirrors land with the R2 Trace route:
"TurnJournalEntrySchema",
"TurnJournalSummarySchema",
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
"PackSummary",
"PackDetail",
"AuditEvent",
"RunSummary",
"RunTurnRef",
"RunDetail",
"VaultSummary",
"VaultEntry",
]);
const UI_ROOT = join(__dirname, "..", "..", "..");
const snapshot: Record<string, string[]> = JSON.parse(
readFileSync(join(UI_ROOT, "schema-snapshot.json"), "utf-8"),
);
const apiTs = readFileSync(join(UI_ROOT, "src", "types", "api.ts"), "utf-8");
function interfaceBlock(name: string): string | null {
const start = apiTs.search(
new RegExp(`export interface ${name}\\b[^{]*\\{`),
);
if (start === -1) return null;
const open = apiTs.indexOf("{", start);
let depth = 0;
for (let i = open; i < apiTs.length; i++) {
if (apiTs[i] === "{") depth++;
else if (apiTs[i] === "}") {
depth--;
if (depth === 0) return apiTs.slice(open, i + 1);
}
}
return null;
}
describe("doctrine: UI types cover engine schemas", () => {
const classes = Object.keys(snapshot).sort();
it("snapshot is non-trivial", () => {
expect(classes.length).toBeGreaterThan(10);
});
for (const pyName of classes) {
const tsName = pyName.replace(/Schema$/, "");
const block = interfaceBlock(tsName);
if (NOT_YET_MIRRORED.has(pyName)) {
it(`${pyName}: still unmirrored (allowlisted debt)`, () => {
expect(
block,
`${tsName} now exists in api.ts — remove ${pyName} from NOT_YET_MIRRORED`,
).toBeNull();
});
continue;
}
it(`${pyName} -> ${tsName}: every field is mirrored`, () => {
expect(block, `no TS interface for ${tsName}`).not.toBeNull();
const missing = snapshot[pyName].filter(
(field) => !new RegExp(`\\b${field}\\??:`).test(block!),
);
expect(missing, `fields missing from ${tsName}: ${missing.join(", ")}`).toEqual([]);
});
}
});

View file

@ -29,6 +29,10 @@
--color-text-mono: #d8e2ef;
--color-text-muted: #788395;
--color-focus-ring: #7dd3fc;
/* Selection is a persistent state; focus is a transient position.
Distinct tokens keep the two visually separable in lists. */
--color-selected-bg: #18222f;
--color-selected-border: #4d9fd6;
--color-link: #8bd3ff;
--color-state-decoded: #4fc3b4;

View file

@ -13,6 +13,8 @@ export const tokens = {
"color-text-mono": "#d8e2ef",
"color-text-muted": "#788395",
"color-focus-ring": "#7dd3fc",
"color-selected-bg": "#18222f",
"color-selected-border": "#4d9fd6",
"color-link": "#8bd3ff",
"color-state-decoded": "#4fc3b4",
"color-state-decoded-muted": "#3d8f86",

View file

@ -1,5 +1,14 @@
import "@testing-library/jest-dom/vitest";
// Node-environment test files (doctrine/*) have no global navigator on
// Node 20 (Node >=21 added one — which is why this only fails in CI).
if (typeof globalThis.navigator === "undefined") {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
}
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {

View file

@ -55,6 +55,8 @@ export interface ProposalRef {
export interface ChatTurnResult {
prompt: string;
/** Journal id stamped by the workbench API; null if journaling failed. */
turn_id?: number | null;
surface: string;
articulation_surface: string | null;
walk_surface: string | null;