diff --git a/.agent/context/20260908T061949Z-c05-frontend-recovery.md b/.agent/context/20260908T061949Z-c05-frontend-recovery.md
new file mode 100644
index 0000000..401f7ad
--- /dev/null
+++ b/.agent/context/20260908T061949Z-c05-frontend-recovery.md
@@ -0,0 +1,111 @@
+# Session Context: C05 frontend recovery
+
+## Date/time
+
+- UTC: 2026-09-08T06:19:49Z
+
+## User goal
+
+Implement Coder C milestone C05, pass FreePi Gate B with GLM 5.3 Flash, then
+start C06.
+
+## Original prompt/request
+
+"we have done all milestones of coders A,B,C to 04. Lets start build milestone
+for coder C 05-frontend-recovery. Before we start check if you can write
+something in npx free-pi-cli or if failed npx.cmd free-pi-cli. Check also
+previos task to understand what have built already. If milestone c05 will be
+finished and gate B will be passed that start with next milestone C 06"
+
+The user later confirmed the repository path on `C:` and instructed PowerShell
+use. The user also required `/model free-pi/glm-5.3-flash` before each FreePi
+review.
+
+## Assumptions
+
+- Gate P4 backend convergence is merged at `25a17d8` and freezes the
+ `recovery-view-v1` semantics implemented by `@oneshot/reconciliation`.
+- Gate P4 did not publish its planned frontend mock artifact. C05 will first
+ publish a C-owned, versioned, sanitized mock boundary without changing the
+ shared OpenAPI or A/B-owned frontend slices.
+- C05 remains an independently composable React/Vite slice. Gate P5 owns final
+ application-shell composition.
+
+## Plan
+
+1. Freeze a versioned, sanitized recovery UI contract and mock fetch server.
+2. Build the recovery route, timeline, provenance, MCP, agent/core, and UNKNOWN
+ experiences.
+3. Add fixture scenarios and component/accessibility/keyboard/responsive tests.
+4. Run package and root checks, FreePi Gate A, draft PR CI, and FreePi Gate B.
+5. Start C06 only after C05 Gate B passes.
+
+## Key decisions
+
+- Keep authoritative OneShot/Arc evidence visually separate from provider,
+ Graph, and LLM observations.
+- Expose refresh and escalation only. Never expose payment or generic retry
+ actions.
+- Discard unknown fields and reject forbidden raw-provider or secret-shaped data
+ at the mock/client boundary.
+
+## Files/components touched
+
+- `packages/recovery-ui/`: independent React/Vite recovery slice, frozen JSON
+ schema, sanitized mock server, fixture stories, styles, and 50 tests.
+- `tsconfig.json`: recovery UI project reference.
+- `pnpm-lock.yaml`: pinned recovery UI dependencies.
+- This context record.
+
+## Commands/checks
+
+- `git fetch origin develop` - PASS.
+- Base: `25a17d86b56822a7e7440d34c331b740cb6d7f04`.
+- `npx.cmd free-pi-cli` interactive write test - PASS; reviewer replied
+ `FREEPI_WRITE_OK`.
+- `pnpm.cmd install --frozen-lockfile --config.confirmModulesPurge=false` - PASS.
+- `pnpm.cmd --filter @oneshot/recovery-ui run verify` - PASS: format, lint,
+ typecheck, 50 tests, and Vite library build.
+- Desktop and 390-pixel viewport browser inspection - PASS; no horizontal
+ overflow and all recovery panels/actions remain usable.
+- `pnpm.cmd lint`, `pnpm.cmd typecheck`, `pnpm.cmd check:generated`, and
+ `pnpm.cmd validate:fixtures` - PASS.
+- `pnpm.cmd test` - first run exposed the existing millisecond-sensitive C03
+ replay test; immediate full rerun passed all 560 tests.
+- `pnpm.cmd format:check` - baseline checkout limitation: Prettier reports 122
+ untouched CRLF files. The focused recovery UI Prettier check passes.
+- `npx.cmd --yes markdownlint-cli2@0.18.1` for the two new Markdown files -
+ PASS.
+- Docker integration checks unavailable because Docker is not installed on this
+ Windows host; C05 adds no database/runtime integration path.
+
+## External-doc findings
+
+- npm registry metadata confirms React 19.2.8 and Vite 8-compatible
+ `@vitejs/plugin-react` 6.1.1.
+
+## Residual risks
+
+- Final app-shell composition remains owned by project Gate P5.
+- Windows root formatting remains red on untouched CRLF files; changed-package
+ formatting is green.
+
+## Git and PR state
+
+- Branch: `milestone/c05-frontend-recovery`.
+- Base: `origin/develop` at `25a17d86b56822a7e7440d34c331b740cb6d7f04`.
+- Commit: uncommitted.
+- PR: not created.
+- CI: not applicable.
+
+## Review gates
+
+- Gate A: NOT RUN.
+- Gate B: NOT RUN.
+
+## Handoff/next steps
+
+1. Implement and validate C05.
+2. Run fresh FreePi Gate A with `free-pi/glm-5.3-flash`.
+3. Push a draft PR, verify CI, and run fresh Gate B.
+4. Begin C06 after Gate B passes.
diff --git a/packages/recovery-ui/README.md b/packages/recovery-ui/README.md
new file mode 100644
index 0000000..3359421
--- /dev/null
+++ b/packages/recovery-ui/README.md
@@ -0,0 +1,84 @@
+# OneShot Recovery UI
+
+`@oneshot/recovery-ui` is the independently composable C05 recovery timeline
+and evidence-history slice. It renders only sanitized recovery data and has no
+settlement submission capability.
+
+## Entry points
+
+- `RecoveryRoute`: fetches and paginates a recovery view through an injected
+ `RecoveryClient`.
+- `RecoveryTimeline`: renders already-validated pages for later Gate P5 shell
+ composition.
+- `createRecoveryClient`: browser client for the frozen mock/API boundary.
+- `createInMemoryRecoveryClient`: deterministic component-test client.
+- `handleRecoveryMockRequest`: request handler used by the Vite development
+ server.
+
+Run the standalone fixture viewer:
+
+```bash
+pnpm --filter @oneshot/recovery-ui dev
+```
+
+Open `/?scenario=aged-unknown`. Any scenario exported by
+`RECOVERY_SCENARIOS` may be selected.
+
+## Frozen mock boundary
+
+- Mock server version: `c05-mock-v1`.
+- Response schema version: `recovery-timeline-v1`.
+- Read endpoint:
+ `GET /mock/v1/intents/{businessIntentId}/recovery?scenario={scenario}&cursor={cursor}`.
+- Safe action endpoints: `POST .../refresh` and `POST .../escalations`.
+- No retry, payment, force-pay, signing, submission, or ownership endpoint
+ exists.
+
+The runtime parser rejects secret-shaped strings and forbidden raw-provider
+fields before data reaches a component. Fixture values are synthetic and
+contain no credentials or raw request/response bodies.
+
+## Fixture stories
+
+Fixtures cover all four advisor recommendations plus invalid output, fresh,
+empty, lagging, unhealthy, unavailable, Graph-disabled fallback,
+contradictory, pending, committed, failed-safe, and aged-`UNKNOWN` states.
+Every fixture has two pages with one repeated observation to prove pagination
+and duplicate collapse. Equal-clock events without a durable sequence are
+visibly marked as order-ambiguous.
+
+## Copy glossary
+
+- **Authoritative OneShot state**: durable OneShot state. It controls whether a
+ terminal transition is allowed.
+- **Authoritative Arc chain evidence**: independently verified receipt and
+ transfer-log proof bound to the intent.
+- **Provider observation**: sanitized Privy status. It is evidence, not final
+ authority.
+- **Candidate discovery**: The Graph result retrieved through Subgraph MCP. It
+ can find candidates but cannot authorize settlement.
+- **LLM recommendation**: one of `WAIT`, `RECONCILE`, `ESCALATE`, or
+ `RETURN_EXISTING_RESULT`. It remains advisory.
+- **Deterministic core disposition**: bounded command enforced by OneShot.
+- **Not observed through block N**: no candidate appeared within the indexed
+ horizon. It never means a settlement did not occur.
+
+## Gate P5 composition
+
+Gate P5 should import `RecoveryRoute` or `RecoveryTimeline`, provide the real
+frozen recovery API client, and import `@oneshot/recovery-ui/styles.css` inside
+the A05-owned shell.
+The shell must retain authority labels, the separate advisor/core panels, and
+the two safe actions. Composition must not introduce a generic retry or payment
+button.
+
+## Verification
+
+```bash
+pnpm --filter @oneshot/recovery-ui verify
+```
+
+Tests cover contract redaction, every fixture story, Graph degradation,
+pagination, duplicate observations, clock ambiguity, keyboard activation,
+responsive breakpoints, React escaping of malicious evidence strings, and
+automated accessibility checks.
diff --git a/packages/recovery-ui/index.html b/packages/recovery-ui/index.html
new file mode 100644
index 0000000..710cdb4
--- /dev/null
+++ b/packages/recovery-ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OneShot recovery evidence
+
+
+
+
+
+
diff --git a/packages/recovery-ui/package.json b/packages/recovery-ui/package.json
new file mode 100644
index 0000000..be585ec
--- /dev/null
+++ b/packages/recovery-ui/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@oneshot/recovery-ui",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Composable recovery timeline and evidence history UI for OneShot.",
+ "exports": {
+ ".": {
+ "types": "./dist/src/index.d.ts",
+ "import": "./dist/recovery-ui.js"
+ },
+ "./styles.css": "./dist/recovery-ui.css"
+ },
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "files": [
+ "dist",
+ "schemas",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsc -b && vite build",
+ "clean": "tsc -b --clean",
+ "dev": "vite",
+ "format": "prettier --check --ignore-path ../../.prettierignore \"**/*.{ts,tsx,json,css,html,md}\"",
+ "format:write": "prettier --write --ignore-path ../../.prettierignore \"**/*.{ts,tsx,json,css,html,md}\"",
+ "lint": "eslint src test vite.config.ts",
+ "test": "vitest run",
+ "typecheck": "tsc -b --pretty false",
+ "verify": "pnpm run format && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build"
+ },
+ "dependencies": {
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.3",
+ "@testing-library/user-event": "14.6.7",
+ "@types/node": "24.13.3",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.7",
+ "@vitejs/plugin-react": "6.1.1",
+ "axe-core": "4.13.0",
+ "jsdom": "30.0.1",
+ "typescript": "6.0.3",
+ "vite": "8.0.0",
+ "vitest": "5.0.0"
+ }
+}
diff --git a/packages/recovery-ui/schemas/recovery-timeline-v1.schema.json b/packages/recovery-ui/schemas/recovery-timeline-v1.schema.json
new file mode 100644
index 0000000..4ff6808
--- /dev/null
+++ b/packages/recovery-ui/schemas/recovery-timeline-v1.schema.json
@@ -0,0 +1,262 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://oneshot.invalid/schemas/recovery-timeline-v1.schema.json",
+ "title": "OneShot recovery timeline UI contract v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schemaVersion",
+ "mockServerVersion",
+ "businessIntentId",
+ "authoritativeState",
+ "stateVersion",
+ "settlementPermission",
+ "evaluatedAt",
+ "summary",
+ "attempts",
+ "timeline",
+ "evidence",
+ "graph",
+ "recommendation",
+ "coreDisposition",
+ "contradiction",
+ "contradictionCodes",
+ "diagnostics",
+ "page"
+ ],
+ "properties": {
+ "schemaVersion": { "const": "recovery-timeline-v1" },
+ "mockServerVersion": { "const": "c05-mock-v1" },
+ "businessIntentId": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "authoritativeState": {
+ "enum": ["SUBMITTING", "UNKNOWN", "COMMITTED", "FAILED_SAFE"]
+ },
+ "stateVersion": { "$ref": "#/$defs/atomicString" },
+ "settlementPermission": { "const": "NEVER" },
+ "evaluatedAt": { "$ref": "#/$defs/dateTime" },
+ "summary": { "$ref": "#/$defs/safeText" },
+ "attempts": {
+ "type": "array",
+ "maxItems": 100,
+ "items": { "$ref": "#/$defs/attempt" }
+ },
+ "timeline": {
+ "type": "array",
+ "maxItems": 100,
+ "items": { "$ref": "#/$defs/timelineEntry" }
+ },
+ "evidence": {
+ "type": "array",
+ "maxItems": 100,
+ "items": { "$ref": "#/$defs/evidence" }
+ },
+ "graph": {
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/graph" }]
+ },
+ "recommendation": { "$ref": "#/$defs/recommendation" },
+ "coreDisposition": { "$ref": "#/$defs/coreDisposition" },
+ "contradiction": { "type": "boolean" },
+ "contradictionCodes": { "$ref": "#/$defs/stringList" },
+ "diagnostics": { "$ref": "#/$defs/stringList" },
+ "page": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["cursor", "nextCursor", "totalEntries"],
+ "properties": {
+ "cursor": { "type": ["string", "null"], "maxLength": 128 },
+ "nextCursor": { "type": ["string", "null"], "maxLength": 128 },
+ "totalEntries": { "type": "integer", "minimum": 0 }
+ }
+ }
+ },
+ "$defs": {
+ "safeText": { "type": "string", "minLength": 1, "maxLength": 1000 },
+ "dateTime": { "type": "string", "format": "date-time", "maxLength": 35 },
+ "atomicString": { "type": "string", "pattern": "^(0|[1-9][0-9]*)$" },
+ "stringList": {
+ "type": "array",
+ "maxItems": 100,
+ "items": { "type": "string", "minLength": 1, "maxLength": 128 }
+ },
+ "source": { "enum": ["ONESHOT", "PRIVY", "ARC", "THE_GRAPH", "LLM"] },
+ "authority": {
+ "enum": [
+ "AUTHORITATIVE_ONESHOT",
+ "AUTHORITATIVE_CHAIN_EVIDENCE",
+ "PROVIDER_OBSERVATION",
+ "NON_AUTHORITATIVE_CANDIDATE_DISCOVERY",
+ "ADVISORY_AGENT_OBSERVATION"
+ ]
+ },
+ "attempt": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["attemptId", "stage", "createdAt", "completedAt", "sanitizedError"],
+ "properties": {
+ "attemptId": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "stage": { "type": "string", "minLength": 1, "maxLength": 64 },
+ "createdAt": { "$ref": "#/$defs/dateTime" },
+ "completedAt": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dateTime" }] },
+ "sanitizedError": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/safeText" }] }
+ }
+ },
+ "timelineEntry": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "eventId",
+ "kind",
+ "timestamp",
+ "sequence",
+ "title",
+ "summary",
+ "source",
+ "authorityClass",
+ "evidenceReferences"
+ ],
+ "properties": {
+ "eventId": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "kind": { "enum": ["ATTEMPT", "TRANSITION", "EVIDENCE", "RECONCILIATION", "DECISION"] },
+ "timestamp": { "$ref": "#/$defs/dateTime" },
+ "sequence": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] },
+ "title": { "$ref": "#/$defs/safeText" },
+ "summary": { "$ref": "#/$defs/safeText" },
+ "source": { "$ref": "#/$defs/source" },
+ "authorityClass": { "$ref": "#/$defs/authority" },
+ "evidenceReferences": { "$ref": "#/$defs/stringList" }
+ }
+ },
+ "evidence": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "evidenceId",
+ "source",
+ "authorityClass",
+ "retrievedAt",
+ "finality",
+ "blockNumber",
+ "digest",
+ "summary",
+ "verifiedBinding",
+ "contradictionCodes"
+ ],
+ "properties": {
+ "evidenceId": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "source": { "$ref": "#/$defs/source" },
+ "authorityClass": { "$ref": "#/$defs/authority" },
+ "retrievedAt": { "$ref": "#/$defs/dateTime" },
+ "finality": { "type": ["string", "null"], "enum": ["FINAL", "PENDING", "UNKNOWN", null] },
+ "blockNumber": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] },
+ "digest": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "summary": { "$ref": "#/$defs/safeText" },
+ "verifiedBinding": { "type": "boolean" },
+ "contradictionCodes": { "$ref": "#/$defs/stringList" }
+ }
+ },
+ "candidate": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "candidateId",
+ "transactionHash",
+ "blockNumber",
+ "bindingStatus",
+ "contradictionCodes"
+ ],
+ "properties": {
+ "candidateId": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "transactionHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" },
+ "blockNumber": { "$ref": "#/$defs/atomicString" },
+ "bindingStatus": { "enum": ["MATCH", "CONTRADICTORY"] },
+ "contradictionCodes": { "$ref": "#/$defs/stringList" }
+ }
+ },
+ "graph": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "retrievalPath",
+ "serverName",
+ "serverVersion",
+ "toolName",
+ "deploymentId",
+ "manifestCid",
+ "observedThroughBlock",
+ "observedThroughTime",
+ "chainHeadBlock",
+ "lagBlocks",
+ "health",
+ "available",
+ "candidateCount",
+ "diagnostics",
+ "candidates"
+ ],
+ "properties": {
+ "retrievalPath": { "const": "SUBGRAPH_MCP" },
+ "serverName": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "serverVersion": { "type": "string", "minLength": 1, "maxLength": 64 },
+ "toolName": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "deploymentId": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "manifestCid": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "observedThroughBlock": {
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }]
+ },
+ "observedThroughTime": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dateTime" }] },
+ "chainHeadBlock": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] },
+ "lagBlocks": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] },
+ "health": { "enum": ["FRESH", "LAGGING", "UNHEALTHY", "UNAVAILABLE", "UNKNOWN_FRESHNESS"] },
+ "available": { "type": "boolean" },
+ "candidateCount": { "type": "integer", "minimum": 0, "maximum": 25 },
+ "diagnostics": { "$ref": "#/$defs/stringList" },
+ "candidates": { "type": "array", "maxItems": 25, "items": { "$ref": "#/$defs/candidate" } }
+ }
+ },
+ "recommendation": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "accepted",
+ "action",
+ "reason",
+ "modelName",
+ "promptVersion",
+ "evidenceReferences"
+ ],
+ "properties": {
+ "accepted": { "type": "boolean" },
+ "action": { "enum": ["WAIT", "RECONCILE", "ESCALATE", "RETURN_EXISTING_RESULT"] },
+ "reason": { "$ref": "#/$defs/safeText" },
+ "modelName": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "promptVersion": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "evidenceReferences": { "$ref": "#/$defs/stringList" }
+ }
+ },
+ "coreDisposition": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "commandType",
+ "targetState",
+ "reason",
+ "authoritativeProofPresent",
+ "evidenceReferences"
+ ],
+ "properties": {
+ "commandType": {
+ "enum": [
+ "HOLD_UNKNOWN",
+ "READ_ONLY_LOOKUP",
+ "ESCALATE_UNKNOWN",
+ "MARK_COMMITTED",
+ "MARK_FAILED_SAFE"
+ ]
+ },
+ "targetState": { "enum": ["UNKNOWN", "COMMITTED", "FAILED_SAFE"] },
+ "reason": { "$ref": "#/$defs/safeText" },
+ "authoritativeProofPresent": { "type": "boolean" },
+ "evidenceReferences": { "$ref": "#/$defs/stringList" }
+ }
+ }
+ }
+}
diff --git a/packages/recovery-ui/src/RecoveryRoute.tsx b/packages/recovery-ui/src/RecoveryRoute.tsx
new file mode 100644
index 0000000..5ac4dc1
--- /dev/null
+++ b/packages/recovery-ui/src/RecoveryRoute.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react';
+
+import type { RecoveryActionReceipt, RecoveryTimelinePage } from './contract.js';
+import type { RecoveryClient } from './mock-server.js';
+import { RecoveryTimeline } from './RecoveryTimeline.js';
+
+export interface RecoveryRouteProps {
+ readonly businessIntentId: string;
+ readonly client: RecoveryClient;
+}
+
+export function RecoveryRoute({ businessIntentId, client }: RecoveryRouteProps) {
+ const [pages, setPages] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ void client
+ .readPage(businessIntentId, null)
+ .then((page) => {
+ if (active) setPages([page]);
+ })
+ .catch(() => {
+ if (active) setError('Recovery evidence is unavailable. Authoritative state is unchanged.');
+ });
+ return () => {
+ active = false;
+ };
+ }, [businessIntentId, client]);
+
+ if (error !== null) {
+ return (
+
+ ONESHOT / RECOVERY CONTROL
+ Evidence unavailable
+ {error}
+
+ );
+ }
+ if (pages.length === 0) {
+ return (
+
+ ONESHOT / RECOVERY CONTROL
+ Loading recovery evidence…
+
+ );
+ }
+
+ const nextCursor = pages.at(-1)?.page.nextCursor ?? null;
+
+ async function refresh(): Promise {
+ const receipt = await client.refresh(businessIntentId);
+ const page = await client.readPage(businessIntentId, null);
+ setPages([page]);
+ return receipt;
+ }
+
+ async function loadMore(): Promise {
+ if (nextCursor === null) return;
+ const page = await client.readPage(businessIntentId, nextCursor);
+ setPages((current) => [...current, page]);
+ }
+
+ return (
+ client.escalate(businessIntentId)}
+ onLoadMore={nextCursor === null ? null : loadMore}
+ />
+ );
+}
diff --git a/packages/recovery-ui/src/RecoveryTimeline.tsx b/packages/recovery-ui/src/RecoveryTimeline.tsx
new file mode 100644
index 0000000..7efa42b
--- /dev/null
+++ b/packages/recovery-ui/src/RecoveryTimeline.tsx
@@ -0,0 +1,395 @@
+import { useMemo, useState } from 'react';
+
+import type {
+ EvidenceSummary,
+ GraphObservationSummary,
+ RecoveryActionReceipt,
+ RecoveryTimelinePage,
+} from './contract.js';
+import { authorityLabel, formatUtc, mergeTimelinePages } from './timeline.js';
+
+export interface RecoveryTimelineProps {
+ readonly pages: readonly RecoveryTimelinePage[];
+ readonly onRefresh: () => Promise;
+ readonly onEscalate: () => Promise;
+ readonly onLoadMore: (() => Promise) | null;
+}
+
+const SOURCE_LABELS: Readonly> = {
+ ONESHOT: 'OneShot',
+ PRIVY: 'Privy',
+ ARC: 'Arc',
+ THE_GRAPH: 'The Graph',
+ LLM: 'Recovery agent',
+};
+
+function stateTone(state: RecoveryTimelinePage['authoritativeState']): string {
+ if (state === 'COMMITTED') return 'success';
+ if (state === 'FAILED_SAFE') return 'neutral';
+ return 'warning';
+}
+
+function shortHash(value: string): string {
+ return value.length <= 18 ? value : `${value.slice(0, 10)}…${value.slice(-8)}`;
+}
+
+function EvidenceCard({ evidence }: { readonly evidence: EvidenceSummary }) {
+ return (
+
+
+
+ {SOURCE_LABELS[evidence.source].slice(0, 1)}
+
+
+
{SOURCE_LABELS[evidence.source]}
+
{authorityLabel(evidence.authorityClass)}
+
+
+ {evidence.verifiedBinding ? 'Binding verified' : 'Binding unverified'}
+
+
+ {evidence.summary}
+
+
+
Retrieved
+ {formatUtc(evidence.retrievedAt)}
+
+ {evidence.blockNumber !== null && (
+
+
Block
+ {evidence.blockNumber}
+
+ )}
+ {evidence.finality !== null && (
+
+
Finality
+ {evidence.finality}
+
+ )}
+
+
Digest
+ {shortHash(evidence.digest)}
+
+
+ {evidence.contradictionCodes.length > 0 && (
+
+ Contradiction: {evidence.contradictionCodes.join(', ')}
+
+ )}
+
+ );
+}
+
+function observationCopy(graph: GraphObservationSummary): string {
+ if (!graph.available || graph.health === 'UNAVAILABLE') return 'Subgraph MCP unavailable.';
+ if (graph.observedThroughBlock === null) return 'Observation height unavailable.';
+ if (graph.candidateCount === 0) {
+ return `Not observed through block ${graph.observedThroughBlock}. This is not settlement evidence.`;
+ }
+ return `${graph.candidateCount} candidate${graph.candidateCount === 1 ? '' : 's'} observed through block ${graph.observedThroughBlock}. Arc verification is still required.`;
+}
+
+function GraphPanel({ graph }: { readonly graph: GraphObservationSummary }) {
+ return (
+
+
+
+
Non-authoritative candidate discovery
+
Subgraph MCP
+
+
{graph.health}
+
+ {observationCopy(graph)}
+
+
+
Retrieval path
+ {graph.retrievalPath}
+
+
+
Tool
+ {graph.toolName}
+
+
+
Server
+
+ {graph.serverName} · {graph.serverVersion}
+
+
+
+
Deployment
+ {shortHash(graph.deploymentId)}
+
+
+
Observed through
+ {graph.observedThroughBlock ?? 'Unavailable'}
+
+
+
Chain head / lag
+
+ {graph.chainHeadBlock ?? 'Unknown'} / {graph.lagBlocks ?? 'Unknown'} blocks
+
+
+
+ {graph.diagnostics.length > 0 && (
+
+ {graph.diagnostics.map((diagnostic) => (
+ {diagnostic}
+ ))}
+
+ )}
+ {graph.candidates.length > 0 && (
+
+ {graph.candidates.map((candidate) => (
+
+
+
Candidate · Block {candidate.blockNumber}
+
{shortHash(candidate.transactionHash)}
+
+
+ {candidate.bindingStatus}
+
+ {candidate.contradictionCodes.length > 0 && (
+
+ {candidate.contradictionCodes.join(', ')}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function RecoveryTimeline({
+ pages,
+ onRefresh,
+ onEscalate,
+ onLoadMore,
+}: RecoveryTimelineProps) {
+ const current = pages[0];
+ const timeline = useMemo(() => mergeTimelinePages(pages), [pages]);
+ const [pendingAction, setPendingAction] = useState<'refresh' | 'escalate' | 'more' | null>(null);
+ const [actionMessage, setActionMessage] = useState('');
+
+ if (!current) return null;
+
+ async function runAction(
+ action: 'refresh' | 'escalate',
+ callback: () => Promise,
+ ): Promise {
+ setPendingAction(action);
+ try {
+ const receipt = await callback();
+ setActionMessage(receipt.message);
+ } catch {
+ setActionMessage('Safe action failed. Authoritative state was not changed.');
+ } finally {
+ setPendingAction(null);
+ }
+ }
+
+ async function loadMore(): Promise {
+ if (onLoadMore === null) return;
+ setPendingAction('more');
+ try {
+ await onLoadMore();
+ } finally {
+ setPendingAction(null);
+ }
+ }
+
+ return (
+
+
+
+
+
+
Authoritative OneShot state
+
{current.authoritativeState}
+
{current.summary}
+
+
+ LOCKED
+ New settlement blocked
+ Settlement permission: {current.settlementPermission}
+
+
+
+ {current.contradiction && (
+
+ Contradictory evidence. {current.contradictionCodes.join(', ')}. Core
+ remains authoritative and blocks a new settlement.
+
+ )}
+
+
+
void runAction('refresh', onRefresh)}
+ >
+ {pendingAction === 'refresh' ? 'Refreshing…' : 'Refresh status'}
+
+
void runAction('escalate', onEscalate)}
+ >
+ {pendingAction === 'escalate' ? 'Escalating…' : 'Escalate to operator'}
+
+
Read and escalation only. No payment action is available.
+
+ {actionMessage}
+
+
+
+
+
+
+
+
Durable history
+
Recovery timeline
+
+
{current.page.totalEntries} events
+
+
+ {timeline.map((entry) => (
+
+
+
+
+ {entry.kind}
+ {formatUtc(entry.timestamp)}
+
+ {entry.title}
+ {entry.summary}
+ {authorityLabel(entry.authorityClass)}
+ {entry.duplicateCount > 0 && (
+
+ Duplicate observation collapsed ×{entry.duplicateCount + 1}
+
+ )}
+ {entry.orderAmbiguous && (
+ Clock tie: relative order is uncertain
+ )}
+
+
+ ))}
+
+ {onLoadMore !== null && (
+ void loadMore()}
+ >
+ {pendingAction === 'more' ? 'Loading…' : 'Load earlier evidence'}
+
+ )}
+
+
+
+
+
+
+
Execution history
+
Attempts
+
+
{current.attempts.length}
+
+
+
+
+
+
+
+
Advice and authority stay separate
+
Decision split
+
+
+
+
+ LLM recommendation
+ {current.recommendation.action}
+ {current.recommendation.reason}
+
+ {current.recommendation.accepted ? 'Boundary accepted' : 'Boundary rejected'} ·{' '}
+ {current.recommendation.modelName}
+
+
+
+ Deterministic core
+ {current.coreDisposition.commandType}
+ {current.coreDisposition.reason}
+
+ Target: {current.coreDisposition.targetState} · Proof:{' '}
+ {current.coreDisposition.authoritativeProofPresent ? 'present' : 'absent'}
+
+
+
+
+
+
+
+ {current.graph !== null && }
+
+
+
+
+
Sanitized provenance
+
Evidence history
+
+
{current.evidence.length} records
+
+
+ {current.evidence.map((evidence) => (
+
+ ))}
+
+
+
+ {current.diagnostics.length > 0 && (
+
+ Safe diagnostics
+
+ {current.diagnostics.map((diagnostic) => (
+ {diagnostic}
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/packages/recovery-ui/src/contract.ts b/packages/recovery-ui/src/contract.ts
new file mode 100644
index 0000000..d1ea910
--- /dev/null
+++ b/packages/recovery-ui/src/contract.ts
@@ -0,0 +1,414 @@
+export const RECOVERY_TIMELINE_VERSION = 'recovery-timeline-v1' as const;
+export const RECOVERY_MOCK_SERVER_VERSION = 'c05-mock-v1' as const;
+
+export type RecoveryState = 'SUBMITTING' | 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE';
+export type RecoveryAction = 'WAIT' | 'RECONCILE' | 'ESCALATE' | 'RETURN_EXISTING_RESULT';
+export type CoreDisposition =
+ 'HOLD_UNKNOWN' | 'READ_ONLY_LOOKUP' | 'ESCALATE_UNKNOWN' | 'MARK_COMMITTED' | 'MARK_FAILED_SAFE';
+export type EvidenceSource = 'ONESHOT' | 'PRIVY' | 'ARC' | 'THE_GRAPH' | 'LLM';
+export type AuthorityClass =
+ | 'AUTHORITATIVE_ONESHOT'
+ | 'AUTHORITATIVE_CHAIN_EVIDENCE'
+ | 'PROVIDER_OBSERVATION'
+ | 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY'
+ | 'ADVISORY_AGENT_OBSERVATION';
+export type IndexHealth = 'FRESH' | 'LAGGING' | 'UNHEALTHY' | 'UNAVAILABLE' | 'UNKNOWN_FRESHNESS';
+export type TimelineKind = 'ATTEMPT' | 'TRANSITION' | 'EVIDENCE' | 'RECONCILIATION' | 'DECISION';
+
+export interface AttemptSummary {
+ readonly attemptId: string;
+ readonly stage: string;
+ readonly createdAt: string;
+ readonly completedAt: string | null;
+ readonly sanitizedError: string | null;
+}
+
+export interface TimelineEntry {
+ readonly eventId: string;
+ readonly kind: TimelineKind;
+ readonly timestamp: string;
+ readonly sequence: string | null;
+ readonly title: string;
+ readonly summary: string;
+ readonly source: EvidenceSource;
+ readonly authorityClass: AuthorityClass;
+ readonly evidenceReferences: readonly string[];
+}
+
+export interface EvidenceSummary {
+ readonly evidenceId: string;
+ readonly source: EvidenceSource;
+ readonly authorityClass: AuthorityClass;
+ readonly retrievedAt: string;
+ readonly finality: 'FINAL' | 'PENDING' | 'UNKNOWN' | null;
+ readonly blockNumber: string | null;
+ readonly digest: string;
+ readonly summary: string;
+ readonly verifiedBinding: boolean;
+ readonly contradictionCodes: readonly string[];
+}
+
+export interface IndexedCandidateSummary {
+ readonly candidateId: string;
+ readonly transactionHash: string;
+ readonly blockNumber: string;
+ readonly bindingStatus: 'MATCH' | 'CONTRADICTORY';
+ readonly contradictionCodes: readonly string[];
+}
+
+export interface GraphObservationSummary {
+ readonly retrievalPath: 'SUBGRAPH_MCP';
+ readonly serverName: string;
+ readonly serverVersion: string;
+ readonly toolName: string;
+ readonly deploymentId: string;
+ readonly manifestCid: string;
+ readonly observedThroughBlock: string | null;
+ readonly observedThroughTime: string | null;
+ readonly chainHeadBlock: string | null;
+ readonly lagBlocks: string | null;
+ readonly health: IndexHealth;
+ readonly available: boolean;
+ readonly candidateCount: number;
+ readonly diagnostics: readonly string[];
+ readonly candidates: readonly IndexedCandidateSummary[];
+}
+
+export interface RecommendationSummary {
+ readonly accepted: boolean;
+ readonly action: RecoveryAction;
+ readonly reason: string;
+ readonly modelName: string;
+ readonly promptVersion: string;
+ readonly evidenceReferences: readonly string[];
+}
+
+export interface CoreDispositionSummary {
+ readonly commandType: CoreDisposition;
+ readonly targetState: 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE';
+ readonly reason: string;
+ readonly authoritativeProofPresent: boolean;
+ readonly evidenceReferences: readonly string[];
+}
+
+export interface RecoveryTimelinePage {
+ readonly schemaVersion: typeof RECOVERY_TIMELINE_VERSION;
+ readonly mockServerVersion: typeof RECOVERY_MOCK_SERVER_VERSION;
+ readonly businessIntentId: string;
+ readonly authoritativeState: RecoveryState;
+ readonly stateVersion: string;
+ readonly settlementPermission: 'NEVER';
+ readonly evaluatedAt: string;
+ readonly summary: string;
+ readonly attempts: readonly AttemptSummary[];
+ readonly timeline: readonly TimelineEntry[];
+ readonly evidence: readonly EvidenceSummary[];
+ readonly graph: GraphObservationSummary | null;
+ readonly recommendation: RecommendationSummary;
+ readonly coreDisposition: CoreDispositionSummary;
+ readonly contradiction: boolean;
+ readonly contradictionCodes: readonly string[];
+ readonly diagnostics: readonly string[];
+ readonly page: {
+ readonly cursor: string | null;
+ readonly nextCursor: string | null;
+ readonly totalEntries: number;
+ };
+}
+
+export interface RecoveryActionReceipt {
+ readonly schemaVersion: 'recovery-action-receipt-v1';
+ readonly businessIntentId: string;
+ readonly action: 'REFRESH_STATUS' | 'ESCALATE';
+ readonly accepted: true;
+ readonly message: string;
+}
+
+const FORBIDDEN_KEYS = new Set([
+ 'accesstoken',
+ 'apikey',
+ 'authorization',
+ 'credential',
+ 'credentials',
+ 'headers',
+ 'privatekey',
+ 'providerbody',
+ 'rawbody',
+ 'rawproviderbody',
+ 'requestbody',
+ 'responsebody',
+ 'secret',
+ 'seedphrase',
+]);
+
+const SECRET_TEXT =
+ /(?:bearer\s+[a-z0-9._~-]+|(?:api[_-]?key|access[_-]?token|private[_-]?key|seed[_-]?phrase)\s*[:=]\s*[^\s,;]+)/iu;
+
+function normalizedKey(key: string): string {
+ return key.replace(/[^a-z0-9]/giu, '').toLowerCase();
+}
+
+function assertSanitized(value: unknown, path = '$', seen = new Set()): void {
+ if (typeof value === 'string') {
+ if (value.length > 1_000 || SECRET_TEXT.test(value)) {
+ throw new Error(`Unsafe recovery UI text at ${path}`);
+ }
+ return;
+ }
+ if (value === null || typeof value !== 'object') return;
+ if (seen.has(value)) return;
+ seen.add(value);
+ if (Array.isArray(value)) {
+ value.forEach((item, index) => assertSanitized(item, `${path}[${index}]`, seen));
+ return;
+ }
+ Object.entries(value as Record).forEach(([key, child]) => {
+ if (FORBIDDEN_KEYS.has(normalizedKey(key))) {
+ throw new Error(`Forbidden recovery UI field at ${path}.${key}`);
+ }
+ assertSanitized(child, `${path}.${key}`, seen);
+ });
+}
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function requireRecord(value: unknown, path: string): Record {
+ if (!isRecord(value)) throw new Error(`Expected object at ${path}`);
+ return value;
+}
+
+function requireString(value: unknown, path: string): string {
+ if (typeof value !== 'string' || value.length === 0)
+ throw new Error(`Expected string at ${path}`);
+ return value;
+}
+
+function requireNullableString(value: unknown, path: string): string | null {
+ return value === null ? null : requireString(value, path);
+}
+
+function requireBoolean(value: unknown, path: string): boolean {
+ if (typeof value !== 'boolean') throw new Error(`Expected boolean at ${path}`);
+ return value;
+}
+
+function requireNumber(value: unknown, path: string): number {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
+ throw new Error(`Expected non-negative integer at ${path}`);
+ }
+ return value;
+}
+
+function requireDate(value: unknown, path: string): string {
+ const date = requireString(value, path);
+ if (!Number.isFinite(Date.parse(date))) throw new Error(`Expected date-time at ${path}`);
+ return date;
+}
+
+function requireEnum(value: unknown, allowed: readonly T[], path: string): T {
+ if (typeof value !== 'string' || !allowed.includes(value as T)) {
+ throw new Error(`Unexpected value at ${path}`);
+ }
+ return value as T;
+}
+
+function requireStringArray(value: unknown, path: string): readonly string[] {
+ if (!Array.isArray(value)) throw new Error(`Expected array at ${path}`);
+ return value.map((item, index) => requireString(item, `${path}[${index}]`));
+}
+
+const RECOVERY_STATES = ['SUBMITTING', 'UNKNOWN', 'COMMITTED', 'FAILED_SAFE'] as const;
+const ACTIONS = ['WAIT', 'RECONCILE', 'ESCALATE', 'RETURN_EXISTING_RESULT'] as const;
+const DISPOSITIONS = [
+ 'HOLD_UNKNOWN',
+ 'READ_ONLY_LOOKUP',
+ 'ESCALATE_UNKNOWN',
+ 'MARK_COMMITTED',
+ 'MARK_FAILED_SAFE',
+] as const;
+const SOURCES = ['ONESHOT', 'PRIVY', 'ARC', 'THE_GRAPH', 'LLM'] as const;
+const AUTHORITIES = [
+ 'AUTHORITATIVE_ONESHOT',
+ 'AUTHORITATIVE_CHAIN_EVIDENCE',
+ 'PROVIDER_OBSERVATION',
+ 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY',
+ 'ADVISORY_AGENT_OBSERVATION',
+] as const;
+const HEALTH = ['FRESH', 'LAGGING', 'UNHEALTHY', 'UNAVAILABLE', 'UNKNOWN_FRESHNESS'] as const;
+const KINDS = ['ATTEMPT', 'TRANSITION', 'EVIDENCE', 'RECONCILIATION', 'DECISION'] as const;
+
+function parseAttempt(value: unknown, path: string): AttemptSummary {
+ const item = requireRecord(value, path);
+ return {
+ attemptId: requireString(item.attemptId, `${path}.attemptId`),
+ stage: requireString(item.stage, `${path}.stage`),
+ createdAt: requireDate(item.createdAt, `${path}.createdAt`),
+ completedAt: requireNullableString(item.completedAt, `${path}.completedAt`),
+ sanitizedError: requireNullableString(item.sanitizedError, `${path}.sanitizedError`),
+ };
+}
+
+function parseTimeline(value: unknown, path: string): TimelineEntry {
+ const item = requireRecord(value, path);
+ return {
+ eventId: requireString(item.eventId, `${path}.eventId`),
+ kind: requireEnum(item.kind, KINDS, `${path}.kind`),
+ timestamp: requireDate(item.timestamp, `${path}.timestamp`),
+ sequence: requireNullableString(item.sequence, `${path}.sequence`),
+ title: requireString(item.title, `${path}.title`),
+ summary: requireString(item.summary, `${path}.summary`),
+ source: requireEnum(item.source, SOURCES, `${path}.source`),
+ authorityClass: requireEnum(item.authorityClass, AUTHORITIES, `${path}.authorityClass`),
+ evidenceReferences: requireStringArray(item.evidenceReferences, `${path}.evidenceReferences`),
+ };
+}
+
+function parseEvidence(value: unknown, path: string): EvidenceSummary {
+ const item = requireRecord(value, path);
+ return {
+ evidenceId: requireString(item.evidenceId, `${path}.evidenceId`),
+ source: requireEnum(item.source, SOURCES, `${path}.source`),
+ authorityClass: requireEnum(item.authorityClass, AUTHORITIES, `${path}.authorityClass`),
+ retrievedAt: requireDate(item.retrievedAt, `${path}.retrievedAt`),
+ finality:
+ item.finality === null
+ ? null
+ : requireEnum(item.finality, ['FINAL', 'PENDING', 'UNKNOWN'] as const, `${path}.finality`),
+ blockNumber: requireNullableString(item.blockNumber, `${path}.blockNumber`),
+ digest: requireString(item.digest, `${path}.digest`),
+ summary: requireString(item.summary, `${path}.summary`),
+ verifiedBinding: requireBoolean(item.verifiedBinding, `${path}.verifiedBinding`),
+ contradictionCodes: requireStringArray(item.contradictionCodes, `${path}.contradictionCodes`),
+ };
+}
+
+function parseCandidate(value: unknown, path: string): IndexedCandidateSummary {
+ const item = requireRecord(value, path);
+ return {
+ candidateId: requireString(item.candidateId, `${path}.candidateId`),
+ transactionHash: requireString(item.transactionHash, `${path}.transactionHash`),
+ blockNumber: requireString(item.blockNumber, `${path}.blockNumber`),
+ bindingStatus: requireEnum(
+ item.bindingStatus,
+ ['MATCH', 'CONTRADICTORY'],
+ `${path}.bindingStatus`,
+ ),
+ contradictionCodes: requireStringArray(item.contradictionCodes, `${path}.contradictionCodes`),
+ };
+}
+
+function parseGraph(value: unknown, path: string): GraphObservationSummary | null {
+ if (value === null) return null;
+ const item = requireRecord(value, path);
+ if (!Array.isArray(item.candidates) || !Array.isArray(item.diagnostics)) {
+ throw new Error(`Expected Graph arrays at ${path}`);
+ }
+ return {
+ retrievalPath: requireEnum(item.retrievalPath, ['SUBGRAPH_MCP'], `${path}.retrievalPath`),
+ serverName: requireString(item.serverName, `${path}.serverName`),
+ serverVersion: requireString(item.serverVersion, `${path}.serverVersion`),
+ toolName: requireString(item.toolName, `${path}.toolName`),
+ deploymentId: requireString(item.deploymentId, `${path}.deploymentId`),
+ manifestCid: requireString(item.manifestCid, `${path}.manifestCid`),
+ observedThroughBlock: requireNullableString(
+ item.observedThroughBlock,
+ `${path}.observedThroughBlock`,
+ ),
+ observedThroughTime: requireNullableString(
+ item.observedThroughTime,
+ `${path}.observedThroughTime`,
+ ),
+ chainHeadBlock: requireNullableString(item.chainHeadBlock, `${path}.chainHeadBlock`),
+ lagBlocks: requireNullableString(item.lagBlocks, `${path}.lagBlocks`),
+ health: requireEnum(item.health, HEALTH, `${path}.health`),
+ available: requireBoolean(item.available, `${path}.available`),
+ candidateCount: requireNumber(item.candidateCount, `${path}.candidateCount`),
+ diagnostics: requireStringArray(item.diagnostics, `${path}.diagnostics`),
+ candidates: item.candidates.map((candidate, index) =>
+ parseCandidate(candidate, `${path}.candidates[${index}]`),
+ ),
+ };
+}
+
+export function parseRecoveryTimelinePage(value: unknown): RecoveryTimelinePage {
+ assertSanitized(value);
+ const input = requireRecord(value, '$');
+ if (
+ !Array.isArray(input.attempts) ||
+ !Array.isArray(input.timeline) ||
+ !Array.isArray(input.evidence)
+ ) {
+ throw new Error('Recovery timeline arrays are required');
+ }
+ const recommendation = requireRecord(input.recommendation, '$.recommendation');
+ const core = requireRecord(input.coreDisposition, '$.coreDisposition');
+ const page = requireRecord(input.page, '$.page');
+ const parsed: RecoveryTimelinePage = {
+ schemaVersion: requireEnum(input.schemaVersion, [RECOVERY_TIMELINE_VERSION], '$.schemaVersion'),
+ mockServerVersion: requireEnum(
+ input.mockServerVersion,
+ [RECOVERY_MOCK_SERVER_VERSION],
+ '$.mockServerVersion',
+ ),
+ businessIntentId: requireString(input.businessIntentId, '$.businessIntentId'),
+ authoritativeState: requireEnum(
+ input.authoritativeState,
+ RECOVERY_STATES,
+ '$.authoritativeState',
+ ),
+ stateVersion: requireString(input.stateVersion, '$.stateVersion'),
+ settlementPermission: requireEnum(
+ input.settlementPermission,
+ ['NEVER'],
+ '$.settlementPermission',
+ ),
+ evaluatedAt: requireDate(input.evaluatedAt, '$.evaluatedAt'),
+ summary: requireString(input.summary, '$.summary'),
+ attempts: input.attempts.map((attempt, index) => parseAttempt(attempt, `$.attempts[${index}]`)),
+ timeline: input.timeline.map((event, index) => parseTimeline(event, `$.timeline[${index}]`)),
+ evidence: input.evidence.map((item, index) => parseEvidence(item, `$.evidence[${index}]`)),
+ graph: parseGraph(input.graph, '$.graph'),
+ recommendation: {
+ accepted: requireBoolean(recommendation.accepted, '$.recommendation.accepted'),
+ action: requireEnum(recommendation.action, ACTIONS, '$.recommendation.action'),
+ reason: requireString(recommendation.reason, '$.recommendation.reason'),
+ modelName: requireString(recommendation.modelName, '$.recommendation.modelName'),
+ promptVersion: requireString(recommendation.promptVersion, '$.recommendation.promptVersion'),
+ evidenceReferences: requireStringArray(
+ recommendation.evidenceReferences,
+ '$.recommendation.evidenceReferences',
+ ),
+ },
+ coreDisposition: {
+ commandType: requireEnum(core.commandType, DISPOSITIONS, '$.coreDisposition.commandType'),
+ targetState: requireEnum(
+ core.targetState,
+ ['UNKNOWN', 'COMMITTED', 'FAILED_SAFE'],
+ '$.coreDisposition.targetState',
+ ),
+ reason: requireString(core.reason, '$.coreDisposition.reason'),
+ authoritativeProofPresent: requireBoolean(
+ core.authoritativeProofPresent,
+ '$.coreDisposition.authoritativeProofPresent',
+ ),
+ evidenceReferences: requireStringArray(
+ core.evidenceReferences,
+ '$.coreDisposition.evidenceReferences',
+ ),
+ },
+ contradiction: requireBoolean(input.contradiction, '$.contradiction'),
+ contradictionCodes: requireStringArray(input.contradictionCodes, '$.contradictionCodes'),
+ diagnostics: requireStringArray(input.diagnostics, '$.diagnostics'),
+ page: {
+ cursor: requireNullableString(page.cursor, '$.page.cursor'),
+ nextCursor: requireNullableString(page.nextCursor, '$.page.nextCursor'),
+ totalEntries: requireNumber(page.totalEntries, '$.page.totalEntries'),
+ },
+ };
+ if (parsed.graph !== null && parsed.graph.candidateCount !== parsed.graph.candidates.length) {
+ throw new Error('Graph candidate count mismatch');
+ }
+ return parsed;
+}
diff --git a/packages/recovery-ui/src/fixtures.ts b/packages/recovery-ui/src/fixtures.ts
new file mode 100644
index 0000000..3f7a897
--- /dev/null
+++ b/packages/recovery-ui/src/fixtures.ts
@@ -0,0 +1,347 @@
+import {
+ RECOVERY_MOCK_SERVER_VERSION,
+ RECOVERY_TIMELINE_VERSION,
+ type CoreDisposition,
+ type GraphObservationSummary,
+ type IndexHealth,
+ type RecoveryAction,
+ type RecoveryState,
+ type RecoveryTimelinePage,
+} from './contract.js';
+
+export const RECOVERY_SCENARIOS = [
+ 'fresh-wait',
+ 'fresh-reconcile',
+ 'fresh-escalate',
+ 'committed-return-existing',
+ 'invalid-output',
+ 'empty',
+ 'lagging',
+ 'unhealthy',
+ 'unavailable',
+ 'fallback-disabled',
+ 'contradictory',
+ 'pending',
+ 'committed',
+ 'failed-safe',
+ 'aged-unknown',
+] as const;
+
+export type RecoveryScenario = (typeof RECOVERY_SCENARIOS)[number];
+
+export function isRecoveryScenario(value: string | null): value is RecoveryScenario {
+ return value !== null && RECOVERY_SCENARIOS.some((scenario) => scenario === value);
+}
+
+const INTENT_ID = 'intent_demo_018f';
+const EVALUATED_AT = '2026-09-08T06:00:30.000Z';
+const TX_HASH = `0x${'a'.repeat(64)}`;
+
+interface ScenarioOptions {
+ readonly state?: RecoveryState;
+ readonly action?: RecoveryAction;
+ readonly accepted?: boolean;
+ readonly commandType?: CoreDisposition;
+ readonly targetState?: 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE';
+ readonly proof?: boolean;
+ readonly graphHealth?: IndexHealth;
+ readonly graphAvailable?: boolean;
+ readonly observedThroughBlock?: string | null;
+ readonly lagBlocks?: string | null;
+ readonly candidateMode?: 'MATCH' | 'EMPTY' | 'CONTRADICTORY';
+ readonly graphHidden?: boolean;
+ readonly contradiction?: boolean;
+ readonly diagnostics?: readonly string[];
+ readonly aged?: boolean;
+}
+
+function graphFixture(options: ScenarioOptions): GraphObservationSummary | null {
+ if (options.graphHidden) return null;
+ const mode = options.candidateMode ?? 'MATCH';
+ const candidates =
+ mode === 'EMPTY'
+ ? []
+ : [
+ {
+ candidateId: 'candidate_arc_701',
+ transactionHash: TX_HASH,
+ blockNumber: '701',
+ bindingStatus:
+ mode === 'CONTRADICTORY' ? ('CONTRADICTORY' as const) : ('MATCH' as const),
+ contradictionCodes:
+ mode === 'CONTRADICTORY' ? (['RECIPIENT_MISMATCH'] as const) : ([] as const),
+ },
+ ];
+ return {
+ retrievalPath: 'SUBGRAPH_MCP',
+ serverName: 'graph-mcp',
+ serverVersion: '1.4.0',
+ toolName: 'execute_query_by_deployment_id',
+ deploymentId: 'QmOneShotArcRecoveryV1',
+ manifestCid: 'QmOneShotManifestV1',
+ observedThroughBlock:
+ options.observedThroughBlock === undefined ? '704' : options.observedThroughBlock,
+ observedThroughTime: '2026-09-08T06:00:18.000Z',
+ chainHeadBlock: '706',
+ lagBlocks: options.lagBlocks === undefined ? '2' : options.lagBlocks,
+ health: options.graphHealth ?? 'FRESH',
+ available: options.graphAvailable ?? true,
+ candidateCount: candidates.length,
+ diagnostics: options.diagnostics ?? [],
+ candidates,
+ };
+}
+
+function makeScenario(options: ScenarioOptions = {}): readonly RecoveryTimelinePage[] {
+ const state = options.state ?? 'UNKNOWN';
+ const action = options.action ?? 'WAIT';
+ const accepted = options.accepted ?? true;
+ const commandType = options.commandType ?? 'HOLD_UNKNOWN';
+ const targetState = options.targetState ?? 'UNKNOWN';
+ const proof = options.proof ?? false;
+ const contradiction = options.contradiction ?? options.candidateMode === 'CONTRADICTORY';
+ const graph = graphFixture(options);
+ const diagnostics = options.diagnostics ?? [];
+ const ageMessage = options.aged
+ ? 'UNKNOWN for 46 minutes. Settlement remains blocked; operator escalation is recommended.'
+ : 'Submission outcome is unresolved. Settlement remains blocked while evidence is reconciled.';
+
+ const common = {
+ schemaVersion: RECOVERY_TIMELINE_VERSION,
+ mockServerVersion: RECOVERY_MOCK_SERVER_VERSION,
+ businessIntentId: INTENT_ID,
+ authoritativeState: state,
+ stateVersion: '12',
+ settlementPermission: 'NEVER' as const,
+ evaluatedAt: EVALUATED_AT,
+ summary: state === 'UNKNOWN' ? ageMessage : `Intent is ${state}.`,
+ attempts: [
+ {
+ attemptId: 'attempt_01',
+ stage: 'UNKNOWN',
+ createdAt: '2026-09-08T05:58:00.000Z',
+ completedAt: null,
+ sanitizedError: 'Provider response was not received after possible submission.',
+ },
+ {
+ attemptId: 'attempt_00',
+ stage: 'FAILED_SAFE',
+ createdAt: '2026-09-08T05:55:00.000Z',
+ completedAt: '2026-09-08T05:55:02.000Z',
+ sanitizedError: 'Authorization was unavailable before submission.',
+ },
+ ],
+ evidence: [
+ {
+ evidenceId: 'oneshot:state:12',
+ source: 'ONESHOT' as const,
+ authorityClass: 'AUTHORITATIVE_ONESHOT' as const,
+ retrievedAt: '2026-09-08T06:00:00.000Z',
+ finality: null,
+ blockNumber: null,
+ digest: 'sha256:local-state-12',
+ summary: `Durable state version 12 records ${state}.`,
+ verifiedBinding: true,
+ contradictionCodes: [] as const,
+ },
+ {
+ evidenceId: 'privy:request:89',
+ source: 'PRIVY' as const,
+ authorityClass: 'PROVIDER_OBSERVATION' as const,
+ retrievedAt: '2026-09-08T06:00:04.000Z',
+ finality: 'UNKNOWN' as const,
+ blockNumber: null,
+ digest: 'sha256:privy-request-89',
+ summary: 'Privy request accepted; transaction identity was not returned.',
+ verifiedBinding: true,
+ contradictionCodes: [] as const,
+ },
+ ...(proof
+ ? [
+ {
+ evidenceId: 'arc:receipt:701',
+ source: 'ARC' as const,
+ authorityClass: 'AUTHORITATIVE_CHAIN_EVIDENCE' as const,
+ retrievedAt: '2026-09-08T06:00:24.000Z',
+ finality: 'FINAL' as const,
+ blockNumber: '701',
+ digest: 'sha256:arc-receipt-701',
+ summary: 'Arc receipt and transfer log match the durable intent binding.',
+ verifiedBinding: true,
+ contradictionCodes: [] as const,
+ },
+ ]
+ : []),
+ ],
+ graph,
+ recommendation: {
+ accepted,
+ action,
+ reason: accepted
+ ? `Advisor recommends ${action} from referenced sanitized evidence.`
+ : 'Advisor output was rejected at the recovery boundary; safe fallback applied.',
+ modelName: 'recovery-advisor-demo',
+ promptVersion: 'recovery-prompt-v1',
+ evidenceReferences: ['oneshot:state:12'],
+ },
+ coreDisposition: {
+ commandType,
+ targetState,
+ reason: proof
+ ? 'Deterministic core found independently verified Arc proof.'
+ : 'Deterministic core found no authoritative proof for a terminal transition.',
+ authoritativeProofPresent: proof,
+ evidenceReferences: proof ? ['oneshot:state:12', 'arc:receipt:701'] : ['oneshot:state:12'],
+ },
+ contradiction,
+ contradictionCodes: contradiction ? (['RECIPIENT_MISMATCH'] as const) : ([] as const),
+ diagnostics,
+ };
+
+ const recent: RecoveryTimelinePage = {
+ ...common,
+ timeline: [
+ {
+ eventId: 'decision-12',
+ kind: 'DECISION',
+ timestamp: '2026-09-08T06:00:30.000Z',
+ sequence: '12',
+ title: `Core disposition: ${commandType}`,
+ summary: common.coreDisposition.reason,
+ source: 'ONESHOT',
+ authorityClass: 'AUTHORITATIVE_ONESHOT',
+ evidenceReferences: common.coreDisposition.evidenceReferences,
+ },
+ {
+ eventId: 'advisor-12',
+ kind: 'DECISION',
+ timestamp: '2026-09-08T06:00:28.000Z',
+ sequence: '11',
+ title: `Advisor: ${action}`,
+ summary: common.recommendation.reason,
+ source: 'LLM',
+ authorityClass: 'ADVISORY_AGENT_OBSERVATION',
+ evidenceReferences: common.recommendation.evidenceReferences,
+ },
+ {
+ eventId: 'graph-observation-10',
+ kind: 'EVIDENCE',
+ timestamp: '2026-09-08T06:00:18.000Z',
+ sequence: null,
+ title: 'Subgraph MCP observation',
+ summary: graph === null ? 'Graph discovery disabled.' : `Graph health: ${graph.health}.`,
+ source: 'THE_GRAPH',
+ authorityClass: 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY',
+ evidenceReferences: graph?.candidates.map((candidate) => candidate.candidateId) ?? [],
+ },
+ ],
+ page: { cursor: null, nextCursor: 'older', totalEntries: 5 },
+ };
+
+ const older: RecoveryTimelinePage = {
+ ...common,
+ timeline: [
+ {
+ eventId: 'graph-observation-10',
+ kind: 'EVIDENCE',
+ timestamp: '2026-09-08T06:00:18.000Z',
+ sequence: null,
+ title: 'Subgraph MCP observation',
+ summary: graph === null ? 'Graph discovery disabled.' : `Graph health: ${graph.health}.`,
+ source: 'THE_GRAPH',
+ authorityClass: 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY',
+ evidenceReferences: graph?.candidates.map((candidate) => candidate.candidateId) ?? [],
+ },
+ {
+ eventId: 'privy-observation-09',
+ kind: 'EVIDENCE',
+ timestamp: '2026-09-08T06:00:04.000Z',
+ sequence: null,
+ title: 'Privy lookup',
+ summary: 'Provider observation has no transaction hash.',
+ source: 'PRIVY',
+ authorityClass: 'PROVIDER_OBSERVATION',
+ evidenceReferences: ['privy:request:89'],
+ },
+ {
+ eventId: 'submission-unknown-08',
+ kind: 'TRANSITION',
+ timestamp: '2026-09-08T06:00:04.000Z',
+ sequence: null,
+ title: 'State changed to UNKNOWN',
+ summary: 'Response was lost after possible submission. No retry right was granted.',
+ source: 'ONESHOT',
+ authorityClass: 'AUTHORITATIVE_ONESHOT',
+ evidenceReferences: ['oneshot:state:12'],
+ },
+ ],
+ page: { cursor: 'older', nextCursor: null, totalEntries: 5 },
+ };
+ return [recent, older];
+}
+
+export const recoveryScenarioPages: Readonly<
+ Record
+> = {
+ 'fresh-wait': makeScenario(),
+ 'fresh-reconcile': makeScenario({ action: 'RECONCILE', commandType: 'READ_ONLY_LOOKUP' }),
+ 'fresh-escalate': makeScenario({ action: 'ESCALATE', commandType: 'ESCALATE_UNKNOWN' }),
+ 'committed-return-existing': makeScenario({
+ state: 'COMMITTED',
+ action: 'RETURN_EXISTING_RESULT',
+ commandType: 'MARK_COMMITTED',
+ targetState: 'COMMITTED',
+ proof: true,
+ }),
+ 'invalid-output': makeScenario({ accepted: false, diagnostics: ['ADVISOR_BOUNDARY_REJECTED'] }),
+ empty: makeScenario({
+ candidateMode: 'EMPTY',
+ observedThroughBlock: '704',
+ diagnostics: ['NO_CANDIDATES'],
+ }),
+ lagging: makeScenario({ graphHealth: 'LAGGING', lagBlocks: '42', diagnostics: ['INDEX_LAG'] }),
+ unhealthy: makeScenario({ graphHealth: 'UNHEALTHY', diagnostics: ['INDEXING_ERRORS'] }),
+ unavailable: makeScenario({
+ graphHealth: 'UNAVAILABLE',
+ graphAvailable: false,
+ observedThroughBlock: null,
+ lagBlocks: null,
+ candidateMode: 'EMPTY',
+ diagnostics: ['MCP_UNAVAILABLE'],
+ }),
+ 'fallback-disabled': makeScenario({
+ graphHidden: true,
+ diagnostics: ['GRAPH_FALLBACK_SELECTED'],
+ }),
+ contradictory: makeScenario({
+ candidateMode: 'CONTRADICTORY',
+ contradiction: true,
+ diagnostics: ['MULTIPLE_CANDIDATES'],
+ }),
+ pending: makeScenario({ state: 'SUBMITTING', commandType: 'HOLD_UNKNOWN' }),
+ committed: makeScenario({
+ state: 'COMMITTED',
+ action: 'RETURN_EXISTING_RESULT',
+ commandType: 'MARK_COMMITTED',
+ targetState: 'COMMITTED',
+ proof: true,
+ }),
+ 'failed-safe': makeScenario({
+ state: 'FAILED_SAFE',
+ commandType: 'MARK_FAILED_SAFE',
+ targetState: 'FAILED_SAFE',
+ }),
+ 'aged-unknown': makeScenario({
+ action: 'ESCALATE',
+ commandType: 'ESCALATE_UNKNOWN',
+ aged: true,
+ diagnostics: ['AGED_UNKNOWN'],
+ }),
+};
+
+export function scenarioPage(
+ scenario: RecoveryScenario,
+ cursor: string | null,
+): RecoveryTimelinePage | null {
+ return recoveryScenarioPages[scenario].find((page) => page.page.cursor === cursor) ?? null;
+}
diff --git a/packages/recovery-ui/src/index.ts b/packages/recovery-ui/src/index.ts
new file mode 100644
index 0000000..b336c72
--- /dev/null
+++ b/packages/recovery-ui/src/index.ts
@@ -0,0 +1,8 @@
+import './styles.css';
+
+export * from './contract.js';
+export * from './fixtures.js';
+export * from './mock-server.js';
+export * from './RecoveryRoute.js';
+export * from './RecoveryTimeline.js';
+export * from './timeline.js';
diff --git a/packages/recovery-ui/src/main.tsx b/packages/recovery-ui/src/main.tsx
new file mode 100644
index 0000000..bdcf7b9
--- /dev/null
+++ b/packages/recovery-ui/src/main.tsx
@@ -0,0 +1,23 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { isRecoveryScenario } from './fixtures.js';
+import { createRecoveryClient } from './mock-server.js';
+import { RecoveryRoute } from './RecoveryRoute.js';
+import './styles.css';
+
+const params = new URLSearchParams(window.location.search);
+const requestedScenario = params.get('scenario');
+const scenario = isRecoveryScenario(requestedScenario) ? requestedScenario : 'aged-unknown';
+const root = document.querySelector('#root');
+
+if (!(root instanceof HTMLElement)) throw new Error('Missing recovery UI root');
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/packages/recovery-ui/src/mock-server.ts b/packages/recovery-ui/src/mock-server.ts
new file mode 100644
index 0000000..f06bc15
--- /dev/null
+++ b/packages/recovery-ui/src/mock-server.ts
@@ -0,0 +1,143 @@
+import {
+ RECOVERY_MOCK_SERVER_VERSION,
+ parseRecoveryTimelinePage,
+ type RecoveryActionReceipt,
+ type RecoveryTimelinePage,
+} from './contract.js';
+import { isRecoveryScenario, scenarioPage, type RecoveryScenario } from './fixtures.js';
+
+export interface MockServerResult {
+ readonly status: number;
+ readonly body: unknown;
+}
+
+export function handleRecoveryMockRequest(
+ requestUrl: string | URL,
+ method = 'GET',
+): MockServerResult | null {
+ const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl, 'http://localhost');
+ const match = /^\/mock\/v1\/intents\/([^/]+)\/recovery(?:\/(refresh|escalations))?$/u.exec(
+ url.pathname,
+ );
+ if (!match) return null;
+
+ const businessIntentId = decodeURIComponent(match[1] ?? '');
+ const action = match[2];
+ const scenarioParam = url.searchParams.get('scenario');
+ const scenario = isRecoveryScenario(scenarioParam) ? scenarioParam : 'aged-unknown';
+
+ if (action === undefined && method === 'GET') {
+ const cursor = url.searchParams.get('cursor');
+ const page = scenarioPage(scenario, cursor);
+ if (page === null) {
+ return {
+ status: 404,
+ body: { code: 'PAGE_NOT_FOUND', message: 'Recovery page does not exist.' },
+ };
+ }
+ return {
+ status: 200,
+ body: { ...page, businessIntentId },
+ };
+ }
+
+ if ((action === 'refresh' || action === 'escalations') && method === 'POST') {
+ const receipt: RecoveryActionReceipt = {
+ schemaVersion: 'recovery-action-receipt-v1',
+ businessIntentId,
+ action: action === 'refresh' ? 'REFRESH_STATUS' : 'ESCALATE',
+ accepted: true,
+ message:
+ action === 'refresh'
+ ? 'Status refresh requested. No settlement action was created.'
+ : 'Operator escalation recorded. Settlement remains blocked.',
+ };
+ return { status: 202, body: receipt };
+ }
+
+ return {
+ status: 405,
+ body: { code: 'METHOD_NOT_ALLOWED', message: 'Only safe recovery reads and actions exist.' },
+ };
+}
+
+export interface RecoveryClient {
+ readPage(businessIntentId: string, cursor: string | null): Promise;
+ refresh(businessIntentId: string): Promise;
+ escalate(businessIntentId: string): Promise;
+}
+
+function parseReceipt(value: unknown): RecoveryActionReceipt {
+ if (value === null || typeof value !== 'object')
+ throw new Error('Invalid recovery action receipt');
+ const receipt = value as Partial;
+ if (
+ receipt.schemaVersion !== 'recovery-action-receipt-v1' ||
+ typeof receipt.businessIntentId !== 'string' ||
+ (receipt.action !== 'REFRESH_STATUS' && receipt.action !== 'ESCALATE') ||
+ receipt.accepted !== true ||
+ typeof receipt.message !== 'string'
+ ) {
+ throw new Error('Invalid recovery action receipt');
+ }
+ return receipt as RecoveryActionReceipt;
+}
+
+export function createRecoveryClient(
+ options: {
+ readonly baseUrl?: string;
+ readonly scenario?: RecoveryScenario;
+ readonly fetcher?: typeof fetch;
+ } = {},
+): RecoveryClient {
+ const baseUrl = options.baseUrl ?? '/mock/v1';
+ const scenario = options.scenario ?? 'aged-unknown';
+ const fetcher = options.fetcher ?? fetch;
+
+ async function request(path: string, init?: RequestInit): Promise {
+ const response = await fetcher(path, init);
+ if (!response.ok) throw new Error(`Recovery mock request failed with ${response.status}`);
+ return response.json() as Promise;
+ }
+
+ return {
+ async readPage(businessIntentId, cursor) {
+ const query = new URLSearchParams({ scenario });
+ if (cursor !== null) query.set('cursor', cursor);
+ const value = await request(
+ `${baseUrl}/intents/${encodeURIComponent(businessIntentId)}/recovery?${query.toString()}`,
+ );
+ return parseRecoveryTimelinePage(value);
+ },
+ async refresh(businessIntentId) {
+ const value = await request(
+ `${baseUrl}/intents/${encodeURIComponent(businessIntentId)}/recovery/refresh?scenario=${scenario}`,
+ { method: 'POST' },
+ );
+ return parseReceipt(value);
+ },
+ async escalate(businessIntentId) {
+ const value = await request(
+ `${baseUrl}/intents/${encodeURIComponent(businessIntentId)}/recovery/escalations?scenario=${scenario}`,
+ { method: 'POST' },
+ );
+ return parseReceipt(value);
+ },
+ };
+}
+
+export function createInMemoryRecoveryClient(scenario: RecoveryScenario): RecoveryClient {
+ const fetcher: typeof fetch = async (input, init) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input : input.url;
+ const result = handleRecoveryMockRequest(url, init?.method ?? 'GET');
+ if (result === null) return new Response('Not found', { status: 404 });
+ return new Response(JSON.stringify(result.body), {
+ status: result.status,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-oneshot-mock-version': RECOVERY_MOCK_SERVER_VERSION,
+ },
+ });
+ };
+ return createRecoveryClient({ baseUrl: 'http://mock.local/mock/v1', scenario, fetcher });
+}
diff --git a/packages/recovery-ui/src/styles.css b/packages/recovery-ui/src/styles.css
new file mode 100644
index 0000000..96d3fd9
--- /dev/null
+++ b/packages/recovery-ui/src/styles.css
@@ -0,0 +1,663 @@
+:root {
+ color: #e8eef7;
+ background: #07101d;
+ font-family: Manrope, Inter, ui-sans-serif, system-ui, sans-serif;
+ font-synthesis: none;
+ color-scheme: dark;
+ --ink: #e8eef7;
+ --muted: #8fa1b8;
+ --line: #203047;
+ --panel: rgba(13, 26, 44, 0.88);
+ --panel-strong: #10243b;
+ --cyan: #64e1ed;
+ --green: #70e2ad;
+ --amber: #ffc65c;
+ --red: #ff7d7d;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ background:
+ linear-gradient(rgba(7, 16, 29, 0.72), rgba(7, 16, 29, 0.96)),
+ repeating-linear-gradient(
+ 90deg,
+ transparent 0,
+ transparent 79px,
+ rgba(100, 225, 237, 0.045) 80px
+ ),
+ radial-gradient(circle at 78% 0%, #16395c 0, transparent 35%), #07101d;
+}
+
+button {
+ font: inherit;
+}
+
+button:focus-visible {
+ outline: 3px solid #fff;
+ outline-offset: 3px;
+}
+
+.recovery-shell,
+.route-state {
+ width: min(1180px, calc(100% - 40px));
+ margin: 0 auto;
+ padding: 48px 0 80px;
+}
+
+.route-state {
+ min-height: 100vh;
+ display: grid;
+ align-content: center;
+}
+
+.hero {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 32px;
+ margin-bottom: 36px;
+}
+
+.brand,
+.eyebrow,
+dt,
+.timeline-meta,
+.authority-label,
+.count,
+.stage,
+.health,
+.binding-ok,
+.binding-warning,
+.duplicate-note,
+.order-note {
+ font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.brand,
+.eyebrow {
+ margin: 0 0 10px;
+ color: var(--cyan);
+ font-size: 0.72rem;
+ font-weight: 500;
+}
+
+h1,
+h2,
+h3,
+p {
+ overflow-wrap: anywhere;
+}
+
+h1 {
+ max-width: 720px;
+ margin: 0;
+ font-size: clamp(2.6rem, 7vw, 5.8rem);
+ line-height: 0.92;
+ letter-spacing: -0.06em;
+}
+
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+
+h2 {
+ margin-bottom: 0;
+ font-size: clamp(1.25rem, 3vw, 1.8rem);
+}
+
+h3 {
+ margin-bottom: 8px;
+ font-size: 1rem;
+}
+
+.lede {
+ margin: 18px 0 0;
+ color: var(--muted);
+ font-size: 1.05rem;
+}
+
+.intent-identity {
+ min-width: min(100%, 320px);
+ margin: 0;
+ padding: 18px 20px;
+ border-left: 1px solid var(--cyan);
+ background: rgba(100, 225, 237, 0.04);
+}
+
+.intent-identity div + div {
+ margin-top: 12px;
+}
+
+dt {
+ color: var(--muted);
+ font-size: 0.64rem;
+}
+
+dd {
+ margin: 4px 0 0;
+ font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace;
+ font-size: 0.79rem;
+}
+
+.state-banner {
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ gap: 28px;
+ padding: clamp(24px, 5vw, 42px);
+ border: 1px solid var(--line);
+ overflow: hidden;
+}
+
+.state-banner::before {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 5px;
+ content: '';
+ background: var(--amber);
+}
+
+.state-banner h2 {
+ margin-bottom: 12px;
+ font-size: clamp(2rem, 5vw, 3.8rem);
+ letter-spacing: -0.04em;
+}
+
+.state-banner p {
+ max-width: 680px;
+ margin-bottom: 0;
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.tone-success::before {
+ background: var(--green);
+}
+
+.tone-neutral::before {
+ background: var(--muted);
+}
+
+.lock-status {
+ display: grid;
+ align-content: center;
+ min-width: 230px;
+ padding: 20px;
+ border: 1px solid rgba(255, 198, 92, 0.42);
+ background: rgba(255, 198, 92, 0.06);
+}
+
+.lock-status span,
+.lock-status small {
+ color: var(--amber);
+ font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace;
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+}
+
+.lock-status strong {
+ margin: 7px 0;
+}
+
+.warning-strip {
+ margin-top: 16px;
+ padding: 17px 20px;
+ border: 1px solid rgba(255, 125, 125, 0.55);
+ background: rgba(255, 125, 125, 0.08);
+ color: #ffd6d6;
+}
+
+.action-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin: 22px 0 34px;
+}
+
+.primary-action,
+.secondary-action,
+.load-more {
+ min-height: 44px;
+ padding: 11px 18px;
+ border: 1px solid var(--cyan);
+ border-radius: 2px;
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.primary-action {
+ background: var(--cyan);
+ color: #07101d;
+}
+
+.secondary-action,
+.load-more {
+ background: transparent;
+ color: var(--cyan);
+}
+
+button:disabled {
+ cursor: wait;
+ opacity: 0.55;
+}
+
+.action-note {
+ margin: 0 0 0 auto;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.sr-live {
+ flex-basis: 100%;
+ min-height: 1.2em;
+ margin: 0;
+ color: var(--green);
+ font-size: 0.82rem;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.85fr);
+ gap: 20px;
+}
+
+.side-stack {
+ display: grid;
+ align-content: start;
+ gap: 20px;
+}
+
+.panel,
+.diagnostics {
+ padding: clamp(22px, 4vw, 32px);
+ border: 1px solid var(--line);
+ background: var(--panel);
+ box-shadow: 0 18px 52px rgba(0, 0, 0, 0.18);
+}
+
+.section-heading,
+.card-heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.count,
+.health,
+.binding-ok,
+.binding-warning,
+.duplicate-note,
+.order-note,
+.stage {
+ display: inline-block;
+ padding: 5px 7px;
+ border: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 0.62rem;
+ white-space: nowrap;
+}
+
+.timeline {
+ margin: 28px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.timeline li {
+ position: relative;
+ display: grid;
+ grid-template-columns: 21px minmax(0, 1fr);
+ gap: 16px;
+ padding-bottom: 30px;
+}
+
+.timeline-rail::before {
+ position: absolute;
+ top: 4px;
+ left: 5px;
+ z-index: 1;
+ width: 11px;
+ height: 11px;
+ border: 2px solid var(--cyan);
+ border-radius: 50%;
+ background: #0b1728;
+ content: '';
+}
+
+.timeline-rail::after {
+ position: absolute;
+ top: 18px;
+ bottom: -4px;
+ left: 10px;
+ width: 1px;
+ background: var(--line);
+ content: '';
+}
+
+.timeline li:last-child .timeline-rail::after {
+ display: none;
+}
+
+.timeline-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--muted);
+ font-size: 0.62rem;
+}
+
+.timeline h3 {
+ margin: 8px 0;
+ font-size: 1.06rem;
+}
+
+.timeline p,
+.attempt-list p,
+.decision-grid p,
+.evidence-card > p,
+.observation-copy {
+ color: var(--muted);
+ font-size: 0.86rem;
+ line-height: 1.55;
+}
+
+.authority-label {
+ margin-bottom: 8px;
+ color: var(--cyan) !important;
+ font-size: 0.62rem !important;
+}
+
+.duplicate-note,
+.order-note {
+ margin: 4px 6px 0 0;
+ white-space: normal;
+}
+
+.order-note {
+ border-color: rgba(255, 198, 92, 0.42);
+ color: var(--amber);
+}
+
+.load-more {
+ width: 100%;
+}
+
+.attempt-list,
+.diagnostic-list,
+.diagnostics ul {
+ margin: 24px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.attempt-list li {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 7px 12px;
+ padding: 16px 0;
+ border-top: 1px solid var(--line);
+}
+
+.attempt-list li > div {
+ display: grid;
+ gap: 4px;
+}
+
+.attempt-list span:not(.stage) {
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.attempt-list p {
+ grid-column: 1 / -1;
+ margin-bottom: 0;
+}
+
+.decision-grid {
+ display: grid;
+ gap: 12px;
+ margin-top: 22px;
+}
+
+.decision-grid article {
+ padding: 18px;
+ border: 1px solid rgba(100, 225, 237, 0.22);
+ background: rgba(100, 225, 237, 0.035);
+}
+
+.decision-grid article > span,
+.decision-grid small {
+ display: block;
+ color: var(--muted);
+ font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace;
+ font-size: 0.66rem;
+ text-transform: uppercase;
+}
+
+.decision-grid strong {
+ display: block;
+ margin-top: 8px;
+ color: var(--cyan);
+}
+
+.decision-grid p {
+ margin: 10px 0;
+}
+
+.decision-grid .core-decision {
+ border-color: rgba(112, 226, 173, 0.35);
+ background: rgba(112, 226, 173, 0.04);
+}
+
+.decision-grid .core-decision strong {
+ color: var(--green);
+}
+
+.graph-panel,
+.panel + .panel,
+.dashboard-grid + .panel,
+.graph-panel + .panel,
+.diagnostics {
+ margin-top: 20px;
+}
+
+.health-fresh,
+.binding-ok {
+ border-color: rgba(112, 226, 173, 0.4);
+ color: var(--green);
+}
+
+.health-lagging,
+.health-unknown_freshness {
+ border-color: rgba(255, 198, 92, 0.45);
+ color: var(--amber);
+}
+
+.health-unhealthy,
+.health-unavailable,
+.binding-warning {
+ border-color: rgba(255, 125, 125, 0.45);
+ color: var(--red);
+}
+
+.identity-grid,
+.compact-facts {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1px;
+ margin: 22px 0 0;
+ background: var(--line);
+}
+
+.identity-grid div,
+.compact-facts div {
+ min-width: 0;
+ padding: 14px;
+ background: var(--panel-strong);
+}
+
+.identity-grid dd,
+.compact-facts dd {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.diagnostic-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.diagnostic-list li,
+.diagnostics li {
+ padding: 7px 10px;
+ border: 1px solid rgba(255, 198, 92, 0.35);
+ color: var(--amber);
+ font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace;
+ font-size: 0.7rem;
+}
+
+.candidate-list,
+.evidence-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 22px;
+}
+
+.candidate,
+.evidence-card {
+ min-width: 0;
+ padding: 18px;
+ border: 1px solid var(--line);
+ background: rgba(3, 10, 19, 0.35);
+}
+
+.candidate {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+}
+
+.candidate .contradiction {
+ grid-column: 1 / -1;
+}
+
+.source-mark {
+ display: grid;
+ flex: 0 0 34px;
+ width: 34px;
+ height: 34px;
+ place-items: center;
+ border: 1px solid var(--cyan);
+ color: var(--cyan);
+ font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace;
+}
+
+.card-heading > div {
+ flex: 1;
+}
+
+.card-heading h3 {
+ margin-bottom: 4px;
+}
+
+.card-heading .eyebrow {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.58rem;
+}
+
+.compact-facts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.compact-facts div {
+ padding: 10px;
+}
+
+.contradiction {
+ margin: 14px 0 0 !important;
+ color: var(--red) !important;
+}
+
+.diagnostics h2 {
+ font-size: 1rem;
+}
+
+@media (max-width: 860px) {
+ .hero,
+ .state-banner {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .intent-identity,
+ .lock-status {
+ min-width: 0;
+ }
+
+ .action-note {
+ flex-basis: 100%;
+ margin-left: 0;
+ }
+}
+
+@media (max-width: 620px) {
+ .recovery-shell,
+ .route-state {
+ width: min(100% - 24px, 1180px);
+ padding-top: 28px;
+ }
+
+ .state-banner,
+ .panel,
+ .diagnostics {
+ padding: 20px;
+ }
+
+ .identity-grid,
+ .compact-facts,
+ .candidate-list,
+ .evidence-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .primary-action,
+ .secondary-action {
+ width: 100%;
+ }
+
+ .timeline-meta {
+ align-items: start;
+ flex-direction: column;
+ }
+
+ .card-heading {
+ flex-wrap: wrap;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/packages/recovery-ui/src/timeline.ts b/packages/recovery-ui/src/timeline.ts
new file mode 100644
index 0000000..effe60e
--- /dev/null
+++ b/packages/recovery-ui/src/timeline.ts
@@ -0,0 +1,81 @@
+import type { RecoveryTimelinePage, TimelineEntry } from './contract.js';
+
+export interface TimelineItem extends TimelineEntry {
+ readonly duplicateCount: number;
+ readonly orderAmbiguous: boolean;
+}
+
+function compareSequence(left: string | null, right: string | null): number | null {
+ if (left === null || right === null || !/^\d+$/u.test(left) || !/^\d+$/u.test(right)) {
+ return null;
+ }
+ const leftValue = BigInt(left);
+ const rightValue = BigInt(right);
+ return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
+}
+
+export function mergeTimelinePages(
+ pages: readonly RecoveryTimelinePage[],
+): readonly TimelineItem[] {
+ const byId = new Map<
+ string,
+ { entry: TimelineEntry; duplicateCount: number; firstSeen: number }
+ >();
+ let seen = 0;
+
+ for (const page of pages) {
+ for (const entry of page.timeline) {
+ const existing = byId.get(entry.eventId);
+ if (existing) {
+ existing.duplicateCount += 1;
+ } else {
+ byId.set(entry.eventId, { entry, duplicateCount: 0, firstSeen: seen });
+ }
+ seen += 1;
+ }
+ }
+
+ const ordered = [...byId.values()].sort((left, right) => {
+ const sequence = compareSequence(left.entry.sequence, right.entry.sequence);
+ if (sequence !== null && sequence !== 0) return sequence;
+ const time = Date.parse(left.entry.timestamp) - Date.parse(right.entry.timestamp);
+ return time === 0 ? left.firstSeen - right.firstSeen : time;
+ });
+
+ return ordered.map((item, index) => {
+ const previous = ordered[index - 1]?.entry;
+ const next = ordered[index + 1]?.entry;
+ const sameClockWithoutSequence = (other: TimelineEntry | undefined): boolean =>
+ other !== undefined &&
+ item.entry.timestamp === other.timestamp &&
+ (item.entry.sequence === null || other.sequence === null);
+ return {
+ ...item.entry,
+ duplicateCount: item.duplicateCount,
+ orderAmbiguous: sameClockWithoutSequence(previous) || sameClockWithoutSequence(next),
+ };
+ });
+}
+
+export function formatUtc(value: string): string {
+ return new Intl.DateTimeFormat('en', {
+ dateStyle: 'medium',
+ timeStyle: 'medium',
+ timeZone: 'UTC',
+ }).format(new Date(value));
+}
+
+export function authorityLabel(authority: TimelineEntry['authorityClass']): string {
+ switch (authority) {
+ case 'AUTHORITATIVE_ONESHOT':
+ return 'Authoritative · OneShot';
+ case 'AUTHORITATIVE_CHAIN_EVIDENCE':
+ return 'Authoritative · Arc chain';
+ case 'PROVIDER_OBSERVATION':
+ return 'Observation · Provider';
+ case 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY':
+ return 'Candidate discovery · Non-authoritative';
+ case 'ADVISORY_AGENT_OBSERVATION':
+ return 'Advisory · LLM';
+ }
+}
diff --git a/packages/recovery-ui/src/vite-env.d.ts b/packages/recovery-ui/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/packages/recovery-ui/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/recovery-ui/test/component.test.ts b/packages/recovery-ui/test/component.test.ts
new file mode 100644
index 0000000..9ec1d84
--- /dev/null
+++ b/packages/recovery-ui/test/component.test.ts
@@ -0,0 +1,175 @@
+// @vitest-environment jsdom
+
+import { readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import axe from 'axe-core';
+import { createElement } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { parseRecoveryTimelinePage, type RecoveryActionReceipt } from '../src/contract.js';
+import { RECOVERY_SCENARIOS, recoveryScenarioPages } from '../src/fixtures.js';
+import { RecoveryTimeline } from '../src/RecoveryTimeline.js';
+
+const receipt: RecoveryActionReceipt = {
+ schemaVersion: 'recovery-action-receipt-v1',
+ businessIntentId: 'intent_demo_018f',
+ action: 'REFRESH_STATUS',
+ accepted: true,
+ message: 'Status refresh requested. No settlement action was created.',
+};
+
+afterEach(() => cleanup());
+
+function renderScenario(scenario: keyof typeof recoveryScenarioPages, allPages = false) {
+ const pages = recoveryScenarioPages[scenario];
+ return render(
+ createElement(RecoveryTimeline, {
+ pages: allPages ? pages : [pages[0]!],
+ onRefresh: vi.fn(async () => receipt),
+ onEscalate: vi.fn(async () => ({ ...receipt, action: 'ESCALATE' as const })),
+ onLoadMore: allPages ? null : vi.fn(async () => undefined),
+ }),
+ );
+}
+
+describe('RecoveryTimeline', () => {
+ it.each([
+ ['fresh-wait', 'WAIT'],
+ ['fresh-reconcile', 'RECONCILE'],
+ ['fresh-escalate', 'ESCALATE'],
+ ['committed-return-existing', 'RETURN_EXISTING_RESULT'],
+ ] as const)(
+ 'renders %s recommendation separately from the deterministic core',
+ (scenario, action) => {
+ renderScenario(scenario);
+ expect(screen.getByText(action, { selector: '.decision-grid strong' })).toBeTruthy();
+ expect(screen.getByText('Deterministic core', { selector: 'span' })).toBeTruthy();
+ expect(screen.getByText('LLM recommendation', { selector: 'span' })).toBeTruthy();
+ },
+ );
+
+ it.each([
+ ['invalid-output', 'ADVISOR_BOUNDARY_REJECTED'],
+ ['lagging', 'LAGGING'],
+ ['unhealthy', 'UNHEALTHY'],
+ ['unavailable', 'UNAVAILABLE'],
+ ['contradictory', 'Contradictory evidence'],
+ ['pending', 'SUBMITTING'],
+ ['committed', 'COMMITTED'],
+ ['failed-safe', 'FAILED_SAFE'],
+ ['aged-unknown', 'UNKNOWN for 46 minutes'],
+ ] as const)('renders safe %s state', (scenario, expected) => {
+ renderScenario(scenario);
+ expect(screen.getAllByText(new RegExp(expected, 'u')).length).toBeGreaterThan(0);
+ expect(screen.getByText(/New settlement blocked/u)).toBeTruthy();
+ });
+
+ it('uses observed-through language for an empty Graph result', () => {
+ renderScenario('empty');
+ expect(screen.getByText(/Not observed through block 704/u)).toBeTruthy();
+ expect(document.body.textContent?.toLowerCase()).not.toContain('not paid');
+ });
+
+ it('hides Subgraph MCP cleanly when fallback is selected', () => {
+ renderScenario('fallback-disabled');
+ expect(screen.queryByRole('heading', { name: 'Subgraph MCP' })).toBeNull();
+ });
+
+ it('offers no retry, force-pay, or settlement action', () => {
+ renderScenario('aged-unknown');
+ const buttonNames = screen
+ .getAllByRole('button')
+ .map((button) => button.textContent?.toLowerCase());
+ expect(buttonNames).toEqual([
+ 'refresh status',
+ 'escalate to operator',
+ 'load earlier evidence',
+ ]);
+ expect(document.body.textContent).toContain('No payment action is available');
+ });
+
+ it('supports keyboard activation for safe actions', async () => {
+ const user = userEvent.setup();
+ const onRefresh = vi.fn(async () => receipt);
+ render(
+ createElement(RecoveryTimeline, {
+ pages: [recoveryScenarioPages['fresh-wait'][0]!],
+ onRefresh,
+ onEscalate: vi.fn(async () => ({ ...receipt, action: 'ESCALATE' as const })),
+ onLoadMore: null,
+ }),
+ );
+ await user.tab();
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Refresh status' }));
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce());
+ expect(await screen.findByText(/Status refresh requested/u)).toBeTruthy();
+ });
+
+ it('renders malicious evidence strings as inert text', () => {
+ const original = structuredClone(recoveryScenarioPages['fresh-wait'][0]!);
+ const fixture = {
+ ...original,
+ evidence: original.evidence.map((item, index) =>
+ index === 0 ? { ...item, summary: ' ' } : item,
+ ),
+ };
+ const safe = parseRecoveryTimelinePage(fixture);
+ render(
+ createElement(RecoveryTimeline, {
+ pages: [safe],
+ onRefresh: vi.fn(async () => receipt),
+ onEscalate: vi.fn(async () => ({ ...receipt, action: 'ESCALATE' as const })),
+ onLoadMore: null,
+ }),
+ );
+ expect(screen.getByText(' ')).toBeTruthy();
+ expect(document.querySelector('img')).toBeNull();
+ });
+
+ it('collapses duplicate observations and flags clock ambiguity after pagination', () => {
+ renderScenario('fresh-wait', true);
+ expect(screen.getByText(/Duplicate observation collapsed ×2/u)).toBeTruthy();
+ expect(screen.getAllByText(/relative order is uncertain/u)).toHaveLength(2);
+ });
+
+ it('has no detectable automated accessibility violations', async () => {
+ const { container } = renderScenario('contradictory', true);
+ const results = await axe.run(container, { rules: { 'color-contrast': { enabled: false } } });
+ expect(results.violations).toEqual([]);
+ });
+
+ it('ships narrow and medium responsive breakpoints', async () => {
+ const repositoryRoot = process.cwd().endsWith('recovery-ui')
+ ? process.cwd()
+ : join(process.cwd(), 'packages', 'recovery-ui');
+ const css = await readFile(join(repositoryRoot, 'src', 'styles.css'), 'utf8');
+ expect(css).toContain('@media (max-width: 860px)');
+ expect(css).toContain('@media (max-width: 620px)');
+ expect(css).toContain('grid-template-columns: 1fr');
+ });
+
+ it('covers every required C05 fixture story', () => {
+ expect(RECOVERY_SCENARIOS).toEqual(
+ expect.arrayContaining([
+ 'fresh-wait',
+ 'fresh-reconcile',
+ 'fresh-escalate',
+ 'committed-return-existing',
+ 'invalid-output',
+ 'empty',
+ 'lagging',
+ 'unhealthy',
+ 'unavailable',
+ 'contradictory',
+ 'pending',
+ 'committed',
+ 'failed-safe',
+ 'aged-unknown',
+ ]),
+ );
+ });
+});
diff --git a/packages/recovery-ui/test/contract.test.ts b/packages/recovery-ui/test/contract.test.ts
new file mode 100644
index 0000000..0ca8d8e
--- /dev/null
+++ b/packages/recovery-ui/test/contract.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseRecoveryTimelinePage } from '../src/contract.js';
+import { RECOVERY_SCENARIOS, recoveryScenarioPages } from '../src/fixtures.js';
+
+describe('recovery UI contract', () => {
+ it.each(RECOVERY_SCENARIOS)('accepts sanitized %s fixtures', (scenario) => {
+ for (const page of recoveryScenarioPages[scenario]) {
+ expect(parseRecoveryTimelinePage(structuredClone(page))).toEqual(page);
+ }
+ });
+
+ it.each(['rawProviderBody', 'authorization', 'private_key', 'responseBody'])(
+ 'rejects forbidden %s fields before component input',
+ (field) => {
+ const page = structuredClone(recoveryScenarioPages['fresh-wait'][0]);
+ expect(page).toBeDefined();
+ const unsafe = { ...page, [field]: { hidden: true } };
+ expect(() => parseRecoveryTimelinePage(unsafe)).toThrow(/Forbidden recovery UI field/u);
+ },
+ );
+
+ it('rejects secret-shaped text before component input', () => {
+ const page = structuredClone(recoveryScenarioPages['fresh-wait'][0]);
+ expect(page).toBeDefined();
+ const unsafe = { ...page, summary: 'Authorization: Bearer abc.def.secret' };
+ expect(() => parseRecoveryTimelinePage(unsafe)).toThrow(/Unsafe recovery UI text/u);
+ });
+
+ it('rejects a mismatched candidate count', () => {
+ const page = structuredClone(recoveryScenarioPages['fresh-wait'][0]);
+ expect(page?.graph).not.toBeNull();
+ const unsafe = { ...page, graph: { ...page?.graph, candidateCount: 99 } };
+ expect(() => parseRecoveryTimelinePage(unsafe)).toThrow(/candidate count mismatch/u);
+ });
+});
diff --git a/packages/recovery-ui/test/mock-server.test.ts b/packages/recovery-ui/test/mock-server.test.ts
new file mode 100644
index 0000000..2671ba8
--- /dev/null
+++ b/packages/recovery-ui/test/mock-server.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+
+import { RECOVERY_MOCK_SERVER_VERSION } from '../src/contract.js';
+import { createInMemoryRecoveryClient, handleRecoveryMockRequest } from '../src/mock-server.js';
+
+describe('C05 frozen mock server', () => {
+ it('serves versioned paginated recovery data', async () => {
+ const client = createInMemoryRecoveryClient('aged-unknown');
+ const first = await client.readPage('intent_custom', null);
+ const second = await client.readPage('intent_custom', first.page.nextCursor);
+
+ expect(first.mockServerVersion).toBe(RECOVERY_MOCK_SERVER_VERSION);
+ expect(first.businessIntentId).toBe('intent_custom');
+ expect(first.page.nextCursor).toBe('older');
+ expect(second.page.cursor).toBe('older');
+ });
+
+ it('exposes refresh and escalation only', async () => {
+ const client = createInMemoryRecoveryClient('aged-unknown');
+ await expect(client.refresh('intent_custom')).resolves.toMatchObject({
+ action: 'REFRESH_STATUS',
+ accepted: true,
+ });
+ await expect(client.escalate('intent_custom')).resolves.toMatchObject({
+ action: 'ESCALATE',
+ accepted: true,
+ });
+ expect(
+ handleRecoveryMockRequest(
+ 'http://mock.local/mock/v1/intents/intent_custom/recovery/retry',
+ 'POST',
+ ),
+ ).toBeNull();
+ });
+
+ it('fails closed on mutating methods for recovery reads', () => {
+ expect(
+ handleRecoveryMockRequest(
+ 'http://mock.local/mock/v1/intents/intent_custom/recovery?scenario=fresh-wait',
+ 'DELETE',
+ ),
+ ).toMatchObject({ status: 405 });
+ });
+});
diff --git a/packages/recovery-ui/test/route.test.ts b/packages/recovery-ui/test/route.test.ts
new file mode 100644
index 0000000..50901ef
--- /dev/null
+++ b/packages/recovery-ui/test/route.test.ts
@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { createElement } from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { createInMemoryRecoveryClient } from '../src/mock-server.js';
+import { RecoveryRoute } from '../src/RecoveryRoute.js';
+
+afterEach(() => cleanup());
+
+describe('RecoveryRoute', () => {
+ it('loads the frozen mock and paginates earlier evidence', async () => {
+ const user = userEvent.setup();
+ render(
+ createElement(RecoveryRoute, {
+ businessIntentId: 'intent_route_test',
+ client: createInMemoryRecoveryClient('fresh-wait'),
+ }),
+ );
+
+ expect(await screen.findByText('intent_route_test')).toBeTruthy();
+ await user.click(screen.getByRole('button', { name: 'Load earlier evidence' }));
+ expect(await screen.findByText(/Duplicate observation collapsed ×2/u)).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Load earlier evidence' })).toBeNull();
+ });
+
+ it('fails closed when the evidence service is unavailable', async () => {
+ render(
+ createElement(RecoveryRoute, {
+ businessIntentId: 'intent_route_test',
+ client: {
+ readPage: async () => Promise.reject(new Error('offline')),
+ refresh: async () => Promise.reject(new Error('offline')),
+ escalate: async () => Promise.reject(new Error('offline')),
+ },
+ }),
+ );
+ expect((await screen.findByRole('alert')).textContent).toMatch(
+ /Authoritative state is unchanged/u,
+ );
+ });
+});
diff --git a/packages/recovery-ui/test/timeline.test.ts b/packages/recovery-ui/test/timeline.test.ts
new file mode 100644
index 0000000..f23a3ac
--- /dev/null
+++ b/packages/recovery-ui/test/timeline.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+
+import { recoveryScenarioPages } from '../src/fixtures.js';
+import { mergeTimelinePages } from '../src/timeline.js';
+
+describe('timeline normalization', () => {
+ it('merges pages, collapses duplicate observations, and preserves stable ordering', () => {
+ const timeline = mergeTimelinePages(recoveryScenarioPages['fresh-wait']);
+ expect(timeline).toHaveLength(5);
+ expect(timeline.map((entry) => entry.eventId)).toEqual([
+ 'privy-observation-09',
+ 'submission-unknown-08',
+ 'graph-observation-10',
+ 'advisor-12',
+ 'decision-12',
+ ]);
+ expect(timeline.find((entry) => entry.eventId === 'graph-observation-10')?.duplicateCount).toBe(
+ 1,
+ );
+ });
+
+ it('labels equal-clock events without durable sequence as order ambiguous', () => {
+ const timeline = mergeTimelinePages(recoveryScenarioPages['fresh-wait']);
+ expect(timeline.find((entry) => entry.eventId === 'privy-observation-09')?.orderAmbiguous).toBe(
+ true,
+ );
+ expect(
+ timeline.find((entry) => entry.eventId === 'submission-unknown-08')?.orderAmbiguous,
+ ).toBe(true);
+ });
+});
diff --git a/packages/recovery-ui/tsconfig.json b/packages/recovery-ui/tsconfig.json
new file mode 100644
index 0000000..271c557
--- /dev/null
+++ b/packages/recovery-ui/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "outDir": "dist",
+ "rootDir": ".",
+ "tsBuildInfoFile": "dist/.tsbuildinfo",
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "vite.config.ts"]
+}
diff --git a/packages/recovery-ui/vite.config.ts b/packages/recovery-ui/vite.config.ts
new file mode 100644
index 0000000..6108317
--- /dev/null
+++ b/packages/recovery-ui/vite.config.ts
@@ -0,0 +1,39 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig, type Plugin } from 'vite';
+
+import { RECOVERY_MOCK_SERVER_VERSION } from './src/contract.js';
+import { handleRecoveryMockRequest } from './src/mock-server.js';
+
+function recoveryMockPlugin(): Plugin {
+ return {
+ name: 'oneshot-recovery-mock-v1',
+ configureServer(server) {
+ server.middlewares.use((request, response, next) => {
+ const result = handleRecoveryMockRequest(request.url ?? '/', request.method ?? 'GET');
+ if (result === null) {
+ next();
+ return;
+ }
+ response.statusCode = result.status;
+ response.setHeader('content-type', 'application/json; charset=utf-8');
+ response.setHeader('x-oneshot-mock-version', RECOVERY_MOCK_SERVER_VERSION);
+ response.end(JSON.stringify(result.body));
+ });
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [react(), recoveryMockPlugin()],
+ build: {
+ emptyOutDir: false,
+ lib: {
+ entry: 'src/index.ts',
+ formats: ['es'],
+ fileName: 'recovery-ui',
+ },
+ rollupOptions: {
+ external: ['react', 'react-dom', 'react/jsx-runtime'],
+ },
+ },
+});
diff --git a/packages/recovery-ui/vitest.config.ts b/packages/recovery-ui/vitest.config.ts
new file mode 100644
index 0000000..a44e73c
--- /dev/null
+++ b/packages/recovery-ui/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ include: ['test/**/*.test.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 523e19e..468543b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,7 +37,7 @@ importers:
version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)
vitest:
specifier: 5.0.0
- version: 5.0.0(@types/node@24.13.3)(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+ version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
wrangler:
specifier: 4.127.0
version: 4.127.0
@@ -130,6 +130,52 @@ importers:
packages/reconciliation: {}
+ packages/recovery-ui:
+ dependencies:
+ react:
+ specifier: 19.2.8
+ version: 19.2.8
+ react-dom:
+ specifier: 19.2.8
+ version: 19.2.8(react@19.2.8)
+ devDependencies:
+ '@testing-library/dom':
+ specifier: 10.4.1
+ version: 10.4.1
+ '@testing-library/react':
+ specifier: 16.3.3
+ version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@testing-library/user-event':
+ specifier: 14.6.7
+ version: 14.6.7(@testing-library/dom@10.4.1)
+ '@types/node':
+ specifier: 24.13.3
+ version: 24.13.3
+ '@types/react':
+ specifier: 19.2.18
+ version: 19.2.18
+ '@types/react-dom':
+ specifier: 19.2.7
+ version: 19.2.7(@types/react@19.2.18)
+ '@vitejs/plugin-react':
+ specifier: 6.1.1
+ version: 6.1.1(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+ axe-core:
+ specifier: 4.13.0
+ version: 4.13.0
+ jsdom:
+ specifier: 30.0.1
+ version: 30.0.1(@noble/hashes@1.8.0)
+ typescript:
+ specifier: 6.0.3
+ version: 6.0.3
+ vite:
+ specifier: 8.0.0
+ version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)
+ vitest:
+ specifier: 5.0.0
+ version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+
packages/storage-postgres:
dependencies:
'@oneshot/contracts':
@@ -166,6 +212,14 @@ packages:
'@adraffy/ens-normalize@1.11.1':
resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==}
+ '@asamuzakjp/css-color@6.0.7':
+ resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
+ engines: {node: ^22.13.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
+ engines: {node: ^22.13.0 || >=24.0.0}
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -174,9 +228,17 @@ packages:
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
'@cacheable/memory@2.2.0':
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
@@ -230,6 +292,42 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
+ '@csstools/color-helpers@6.1.1':
+ resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.3.0':
+ resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.2.2':
+ resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.12':
+ resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@emnapi/core@1.11.3':
resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==}
@@ -434,6 +532,15 @@ packages:
resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
@@ -850,6 +957,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.9':
resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==}
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
'@scure/base@1.2.6':
resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==}
@@ -869,9 +979,37 @@ packages:
'@testcontainers/postgresql@12.1.0':
resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/react@16.3.3':
+ resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.7':
+ resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -914,6 +1052,14 @@ packages:
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
+ '@types/react-dom@19.2.7':
+ resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.18':
+ resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
+
'@types/semver@7.8.0':
resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==}
@@ -985,6 +1131,22 @@ packages:
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@vitejs/plugin-react@6.1.1':
+ resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ oxc-transform-react: ^0.145.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ oxc-transform-react:
+ optional: true
+
'@vitest/mocker@5.0.0':
resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==}
peerDependencies:
@@ -1053,6 +1215,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
@@ -1068,6 +1234,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
@@ -1088,6 +1257,10 @@ packages:
avvio@9.3.0:
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
+ axe-core@4.13.0:
+ resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+ engines: {node: '>=4'}
+
b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
@@ -1146,6 +1319,9 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
+ bidi-js@1.1.0:
+ resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
+
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -1243,6 +1419,17 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1252,6 +1439,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1275,6 +1465,9 @@ packages:
resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==}
engines: {node: '>= 14.17'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -1287,6 +1480,10 @@ packages:
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -1490,6 +1687,10 @@ packages:
hookified@2.2.0:
resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1535,6 +1736,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -1560,6 +1764,15 @@ packages:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
+ jsdom@30.0.1:
+ resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ peerDependencies:
+ canvas: ^3.2.3
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@@ -1691,9 +1904,20 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@1.2.3:
resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
miniflare@5.20260826.0-alpha:
resolution: {integrity: sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q==}
engines: {node: '>=22.0.0'}
@@ -1782,6 +2006,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -1884,6 +2111,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -1922,6 +2153,18 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+ react-dom@19.2.8:
+ resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
+ peerDependencies:
+ react: ^19.2.8
+
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
+ react@19.2.8:
+ resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
+ engines: {node: '>=0.10.0'}
+
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -1992,6 +2235,13 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
@@ -2085,6 +2335,9 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tar-fs@2.1.5:
resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
@@ -2124,6 +2377,13 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tldts-core@7.4.11:
+ resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==}
+
+ tldts@7.4.11:
+ resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==}
+ hasBin: true
+
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
engines: {node: '>=14.14'}
@@ -2132,6 +2392,14 @@ packages:
resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
engines: {node: '>=20'}
+ tough-cookie@6.0.2:
+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -2278,6 +2546,26 @@ packages:
jsdom:
optional: true
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ whatwg-url@17.1.0:
+ resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
+ engines: {node: ^22.14.0 || >=24.0.0}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -2330,6 +2618,13 @@ packages:
utf-8-validate:
optional: true
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -2369,6 +2664,21 @@ snapshots:
'@adraffy/ens-normalize@1.11.1': {}
+ '@asamuzakjp/css-color@6.0.7':
+ dependencies:
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.5.2
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ dependencies:
+ bidi-js: 1.1.0
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2377,8 +2687,14 @@ snapshots:
'@babel/helper-validator-identifier@7.29.7': {}
+ '@babel/runtime@7.29.7': {}
+
'@balena/dockerignore@1.0.2': {}
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
'@cacheable/memory@2.2.0':
dependencies:
'@cacheable/utils': 2.5.0
@@ -2418,6 +2734,30 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
+ '@csstools/color-helpers@6.1.1': {}
+
+ '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.1
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@emnapi/core@1.11.3':
dependencies:
'@emnapi/wasi-threads': 1.2.3
@@ -2546,6 +2886,10 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
+ '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)':
+ optionalDependencies:
+ '@noble/hashes': 1.8.0
+
'@fastify/ajv-compiler@4.0.6':
dependencies:
ajv: 8.20.0
@@ -2865,6 +3209,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.9': {}
+ '@rolldown/pluginutils@1.0.1': {}
+
'@scure/base@1.2.6': {}
'@scure/bip32@1.7.0':
@@ -2900,11 +3246,38 @@ snapshots:
- react-native-b4a
- supports-color
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.7(@types/react@19.2.18)
+
+ '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
optional: true
+ '@types/aria-query@5.0.4': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -2957,6 +3330,14 @@ snapshots:
pg-protocol: 1.16.0
pg-types: 2.2.0
+ '@types/react-dom@19.2.7(@types/react@19.2.18)':
+ dependencies:
+ '@types/react': 19.2.18
+
+ '@types/react@19.2.18':
+ dependencies:
+ csstype: 3.2.3
+
'@types/semver@7.8.0': {}
'@types/ssh2-streams@0.1.13':
@@ -3063,6 +3444,11 @@ snapshots:
'@typescript-eslint/types': 8.69.0
eslint-visitor-keys: 5.0.1
+ '@vitejs/plugin-react@6.1.1(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)
+
'@vitest/mocker@5.0.0(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -3116,6 +3502,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.3: {}
archiver-utils@5.0.2:
@@ -3144,6 +3532,10 @@ snapshots:
argparse@2.0.1: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
@@ -3161,6 +3553,8 @@ snapshots:
'@fastify/error': 4.2.0
fastq: 1.20.3
+ axe-core@4.13.0: {}
+
b4a@1.8.1: {}
balanced-match@1.0.2: {}
@@ -3202,6 +3596,10 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
+ bidi-js@1.1.0:
+ dependencies:
+ require-from-string: 2.0.2
+
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -3306,6 +3704,20 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ csstype@3.2.3: {}
+
+ data-urls@7.0.0(@noble/hashes@1.8.0):
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
@@ -3318,6 +3730,8 @@ snapshots:
optionalDependencies:
supports-color: 7.2.0
+ decimal.js@10.6.0: {}
+
deep-is@0.1.4: {}
dequal@2.0.3: {}
@@ -3368,6 +3782,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ dom-accessibility-api@0.5.16: {}
+
eastasianwidth@0.2.0: {}
emoji-regex@8.0.0: {}
@@ -3378,6 +3794,8 @@ snapshots:
dependencies:
once: 1.4.0
+ entities@8.0.0: {}
+
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -3648,6 +4066,12 @@ snapshots:
hookified@2.2.0: {}
+ html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -3677,6 +4101,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-potential-custom-element-name@1.0.1: {}
+
is-stream@2.0.1: {}
isarray@1.0.0: {}
@@ -3699,6 +4125,32 @@ snapshots:
dependencies:
argparse: 2.0.1
+ jsdom@30.0.1(@noble/hashes@1.8.0):
+ dependencies:
+ '@asamuzakjp/css-color': 6.0.7
+ '@asamuzakjp/dom-selector': 8.3.2
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0)
+ css-tree: 3.2.1
+ data-urls: 7.0.0(@noble/hashes@1.8.0)
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0)
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.2
+ undici: 8.10.2
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 17.1.0(@noble/hashes@1.8.0)
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
json-parse-even-better-errors@2.3.1: {}
json-schema-ref-resolver@3.0.0:
@@ -3797,10 +4249,16 @@ snapshots:
lru-cache@10.4.3: {}
+ lru-cache@11.5.2: {}
+
+ lz-string@1.5.0: {}
+
magic-string@1.2.3:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
+ mdn-data@2.27.1: {}
+
miniflare@5.20260826.0-alpha:
dependencies:
'@cspotcode/source-map-support': 0.8.1
@@ -3895,6 +4353,10 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -3989,6 +4451,12 @@ snapshots:
prettier@3.9.6: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
process-nextick-args@2.0.1: {}
process-warning@4.0.1: {}
@@ -4044,6 +4512,15 @@ snapshots:
quick-format-unescaped@4.0.4: {}
+ react-dom@19.2.8(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+ scheduler: 0.27.0
+
+ react-is@17.0.2: {}
+
+ react@19.2.8: {}
+
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -4126,6 +4603,12 @@ snapshots:
safer-buffer@2.1.2: {}
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
+ scheduler@0.27.0: {}
+
secure-json-parse@4.1.0: {}
semver@7.8.5: {}
@@ -4246,6 +4729,8 @@ snapshots:
dependencies:
has-flag: 4.0.0
+ symbol-tree@3.2.4: {}
+
tar-fs@2.1.5:
dependencies:
chownr: 1.1.4
@@ -4356,10 +4841,24 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
+ tldts-core@7.4.11: {}
+
+ tldts@7.4.11:
+ dependencies:
+ tldts-core: 7.4.11
+
tmp@0.2.7: {}
toad-cache@3.7.4: {}
+ tough-cookie@6.0.2:
+ dependencies:
+ tldts: 7.4.11
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -4438,7 +4937,7 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
- vitest@5.0.0(@types/node@24.13.3)(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)):
+ vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
@@ -4456,9 +4955,34 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
+ jsdom: 30.0.1(@noble/hashes@1.8.0)
transitivePeerDependencies:
- msw
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0)
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ whatwg-url@17.1.0(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0)
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -4510,6 +5034,10 @@ snapshots:
ws@8.21.0: {}
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
xtend@4.0.2: {}
y18n@5.0.8: {}
diff --git a/tsconfig.json b/tsconfig.json
index 9e245df..6a69570 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,6 +30,9 @@
},
{
"path": "./apps/worker"
+ },
+ {
+ "path": "./packages/recovery-ui"
}
]
}