Extract binder package from citation-evidence (EBIND-WP-0001)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Extracts citation-evidence/src/binder/ into this repo as the standalone
@citation-evidence/evidence-binder package: headless binding service +
in-memory link repo, active-state machine, the SharedContracts §7
rect-registry contract (registry, change pumps, hooks, SVG overlay), and
the target-neutral reference FormRenderer.

- toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint);
  imports rewritten from @shared/@engine aliases to the engine's
  @citation-evidence/engine package specifiers
- dependency boundary (engine + anchor only; no source/work/umbrella)
  enforced via eslint no-restricted-imports
- docs: extraction inventory + contract deltas, ADR-0001 (reference UI
  kept as supported exports), refreshed README/SCOPE/INTENT, populated
  capabilities index
- typecheck + lint green, 37 tests passing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-09 01:36:33 +02:00
parent 87d4eb3006
commit a10f080f12
36 changed files with 6643 additions and 170 deletions

7
.gitignore vendored
View file

@ -174,3 +174,10 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# ---> Node / TypeScript (evidence-binder is a pnpm TS package)
node_modules/
tsconfig.tsbuildinfo
*.tsbuildinfo
.vitest/
# pnpm-lock.yaml and .nvmrc are intentionally tracked (sibling-repo convention)

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v20.10.0

View file

@ -454,15 +454,18 @@ This repository should be:
--- ---
## MVP Coordination — Code Lives Upstream ## MVP Coordination — Code Now Extracted Here
During the umbrella-first MVP phase (decided 2026-05-24), **the source code During the umbrella-first MVP phase (decided 2026-05-24), the source code for
for this subsystem does not live in this repository yet**. It lives in the this subsystem lived in the umbrella repo at `citation-evidence/src/binder/`.
umbrella repo at `citation-evidence/src/binder/`. **As of EBIND-WP-0001 (2026-07-08) it has been extracted into this repository**
under `src/`, and this repo is now the canonical home of the binder
implementation. See `docs/extraction-inventory.md` for the extraction boundary
and resolved contract deltas, and `README.md` for the current package surface.
This INTENT.md documents the *intended* responsibilities and boundaries. This INTENT.md documents the *intended* responsibilities and boundaries; the
When the binding model and visual-guide rect-registry contract have stabilized implemented surface may be narrower than the aspirational target catalogue above
through actual MVP use, the corresponding code extracts into this repository. (the MVP exercises `form-field` targets).
**Shared contracts** (EvidenceLink.status enum, EvidenceLink.relation enum, **Shared contracts** (EvidenceLink.status enum, EvidenceLink.relation enum,
EvidenceSet shape, rect registry contract, allowed dependency edges) are EvidenceSet shape, rect registry contract, allowed dependency edges) are

View file

@ -1,19 +1,49 @@
# evidence-binder # evidence-binder
Connect evidence items to structured targets — form fields, claims, `@citation-evidence/evidence-binder` — connect evidence items to structured
requirements — and render the visual guide that ties a target, its evidence targets (form fields, claims, requirements, …) and render the visual guide that
card, and the source highlight together in the workspace UI. ties a target, its evidence card, and the source highlight together.
## MVP status: INTENT only This is the canonical home of the binder implementation, extracted from
`citation-evidence/src/binder/` (EBIND-WP-0001).
During the citation-evidence MVP, code lives upstream in ## What this package owns
[`citation-evidence`](../citation-evidence/) under `src/binder/`. This repo
currently holds `INTENT.md` describing what will move here. Contract
changes — including the rect-registry contract that the visual guide
depends on — belong in
[`citation-evidence/wiki/SharedContracts.md`](../citation-evidence/wiki/SharedContracts.md),
not here.
Per the dependency map, binder depends on `shared/`, `engine/`, and - **Evidence links**`EvidenceLink` repository + binding service
`anchor/`. It must not be imported by `work/` (the UI talks to binder via (`services/bindings.ts`, `repos/in-memory-links.ts`). Two query directions:
the active-state context, not by direct import). *all evidence for a target* and *all targets for an evidence item*. Emits the
canonical engine bus events (`EvidenceLinkCreated/Updated/Removed`,
`EvidenceItemActivated`).
- **Active state** — the `(activeTarget, activeEvidenceItemId, activeAnnotationId)`
machine and React provider (`state/active.ts`) that the form UI, evidence
sidebar, and viewer adapter coordinate through.
- **Rect registry contract** (SharedContracts §7) — a single registry into which
independent renderers publish `field` / `evidence-card` / `highlight` rects,
plus the change pumps, React hooks, and the SVG `Overlay` that draws the guide
(`visual-guide/`).
- **Reference form UI**`FormRenderer` and `FieldDefinitionForm`, kept as
supported, target-neutral exports (see `docs/adr/ADR-0001-reference-ui-surface.md`).
`BinderProvider` composes all four concerns behind one mount.
## Dependency boundary
Per `citation-evidence/wiki/DependencyMap.md` §2, this package **may** depend on
`citation-engine` and `evidence-anchor`, and **must not** depend on
`evidence-source`, `citation-work`, or the umbrella `citation-evidence`. The MVP
slice imports only `citation-engine` (shared types + event bus). The boundary is
enforced by `eslint.config.js`. Shared-contract authority stays in the umbrella
wiki.
## Develop
```bash
pnpm install # links @citation-evidence/engine from ../citation-engine
pnpm test # vitest (node + happy-dom suites)
pnpm typecheck # tsc -b --noEmit
pnpm lint # eslint (enforces the dependency boundary)
```
Requires a sibling checkout of `citation-engine` (see `.nvmrc` for the Node
version). See `docs/extraction-inventory.md` for the extraction boundary and
resolved contract deltas.

118
SCOPE.md
View file

@ -8,130 +8,118 @@
## One-liner ## One-liner
<!-- Describe the purpose of this repository in one precise sentence. --> Binds evidence items to structured targets and owns the rect-registry contract
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." --> that drives the citation-evidence visual guide.
--- ---
## Core Idea ## Core Idea
<!-- What is the main capability or idea behind this repository? --> Turn collected evidence into structured, target-bound relationships — "what does
<!-- What problem does it try to solve? --> this evidence support, explain, contradict, or source?" — and coordinate the
active `(target, evidence, annotation)` triple so the workspace can draw a visual
guide from a form field to its evidence card to the source highlight.
--- ---
## In Scope ## In Scope
<!-- What this repository is responsible for. --> - `EvidenceLink` storage + binding service (link/unlink/update, two query
<!-- Be explicit and concrete. --> directions, canonical bus events)
- active-target / active-evidence / active-annotation state machine + provider
- - the SharedContracts §7 rect-registry contract: registry, change pumps, React
- hooks, and the SVG overlay
- - a target-neutral reference `FormRenderer` (+ `FieldDefinitionForm`)
--- ---
## Out of Scope ## Out of Scope
<!-- What this repository deliberately does NOT do. --> - document ingestion, selector creation/resolution, or viewer internals (that is
<!-- This is often more important than "In Scope". --> `evidence-anchor` / the engine)
- evidence capture / source panels (`evidence-source`)
- - the review workspace and app composition (`citation-work`, umbrella `app/`)
- - changing canonical enum vocabularies (owned by the umbrella shared contracts)
- - publish/distribution beyond sibling-checkout linking
--- ---
## Relevant When ## Relevant When
<!-- When should someone consider using or exploring this repository? --> - linking evidence to form fields, claims, requirements, decisions, or sections
- coordinating which target/evidence/annotation is active
- - drawing or consuming the visual guide between panes
-
-
--- ---
## Not Relevant When ## Not Relevant When
<!-- When should someone ignore this repository? --> - you need to import, render, or re-anchor documents (use `evidence-anchor`)
- you need to capture or ingest evidence sources (use `evidence-source`)
- - you are wiring the whole app together (that is the umbrella `citation-evidence`)
-
-
--- ---
## Current State ## Current State
<!-- Rough indication of maturity. No strict format required. --> - Status: active
- Implementation: substantial (headless core + rect-registry + reference UI extracted, tests green)
- Status: <!-- e.g. concept / experimental / active / stable / deprecated --> - Stability: evolving
- Implementation: <!-- e.g. idea / partial / substantial / complete --> - Usage: internal (consumed by the citation-evidence umbrella)
- Stability: <!-- e.g. unstable / evolving / stable -->
- Usage: <!-- e.g. none / personal / internal / production -->
<!-- Add any notes that help set expectations. -->
--- ---
## How It Fits ## How It Fits
<!-- Where does this repository sit in the bigger picture? --> - Upstream dependencies: `citation-engine` (shared types + event bus); `evidence-anchor` (allowed, currently unused)
- Downstream consumers: `citation-evidence` umbrella app
- Upstream dependencies: - Often used with: `evidence-anchor`, `citation-engine`
- Downstream consumers:
- Often used with:
--- ---
## Terminology ## Terminology
<!-- Terms that are important to understand this repo. --> - Preferred terms: evidence link, evidence target, rect registry, active triple, visual guide
<!-- Especially useful if naming differs from other repos. --> - Also known as: "binder"
- Potentially confusing terms: dropped legacy relations `derived-from` / `needs-check` are NOT part of the canonical `EvidenceRelation` enum
- Preferred terms:
- Also known as:
- Potentially confusing terms:
--- ---
## Related / Overlapping Repositories ## Related / Overlapping Repositories
<!-- List repositories that have similar or adjacent responsibilities. --> - citation-engine — owns shared domain types + the event bus this package emits on
<!-- Helps detect duplication and navigate the ecosystem. --> - evidence-anchor — selector/resolution + highlight/scroll contracts (allowed dependency)
- citation-evidence — umbrella app that composes the binder with the rest of the workspace
- <repo-name><!-- how it relates -->
--- ---
## Getting Oriented ## Getting Oriented
<!-- If someone decides to look deeper, where should they start? --> - Start with: `README.md`, then `docs/extraction-inventory.md`
- Key files / directories: `src/services/bindings.ts`, `src/state/active.ts`, `src/visual-guide/rect-registry.ts`, `src/BinderProvider.tsx`
- Start with: - Entry points: `src/index.ts`
- Key files / directories:
- Entry points:
--- ---
## Provided Capabilities ## Provided Capabilities
<!-- What can this repo's domain provide to other domains on request? -->
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
<!-- Remove the examples and add your own, or leave empty if none. -->
<!--
```capability ```capability
type: infrastructure type: library
title: Example capability title title: Evidence-to-target binding
description: What this capability provides, in one or two sentences. description: Links evidence items to structured targets (form fields, claims, requirements) with relation/status/confidence and two query directions, emitting canonical engine bus events.
keywords: [keyword1, keyword2, keyword3] keywords: [evidence, binding, evidence-link, form-field, citation-evidence]
```
```capability
type: library
title: Visual-guide rect registry
description: The SharedContracts §7 rect-registry contract — independent renderers publish field/evidence-card/highlight rects into one registry; an SVG overlay draws the active-triple guide without polling.
keywords: [rect-registry, visual-guide, overlay, active-state, citation-evidence]
``` ```
-->
--- ---
## Notes ## Notes
<!-- Anything else worth knowing. Keep it short. --> Extracted from `citation-evidence/src/binder/` under EBIND-WP-0001. Shared-contract
authority remains in the umbrella wiki.

View file

@ -0,0 +1,49 @@
# ADR-0001 — Binder-owned reference UI surface
- Status: accepted
- Date: 2026-07-08
- Workplan: EBIND-WP-0001 (T05)
## Context
The extracted binder slice includes two small React components:
- `FormRenderer.tsx` — renders a `FormSchema` as an evidence-backed form. Each
field registers itself with the rect registry under `kind="field"`, focuses a
target via the active-state machine, and shows a link-count chip. This is the
component that produces the `field` leg of the visual-guide triple.
- `FieldDefinitionForm.tsx` — a shared label/type editor used by `FormRenderer`'s
add-field and edit-field flows.
The architectural rule is that the binder is *form-friendly but target-neutral*:
it must not depend on `citation-work` and must not assume any particular host
application.
## Decision
**Keep both components as supported binder exports.**
- `FormRenderer` is contractual: it is the reference implementation of the
"form field publishes a `field` rect and drives active-target focus" half of
the rect-registry contract (SharedContracts §7). Demoting it to an example
would leave the §7 field-side contract without an in-repo reference.
- `FieldDefinitionForm` is retained as a supporting export because `FormRenderer`
depends on it directly; splitting them across the package/example boundary
would break that import.
Both remain **target-neutral**: they operate purely on `FormSchema` /
`EvidenceTarget` and the binder's own hooks. They take no dependency on
`citation-work`, `evidence-source`, or the umbrella. The umbrella owns the
*composition* (wiring `FormRenderer` to persistence, sidebars, and the viewer)
in `citation-evidence/src/app/**`; the binder owns only the renderer itself.
## Consequences
- `src/index.ts` continues to export `FormRenderer` (and its
`FormSchema` / `FormFieldSchema` / `FormRendererProps` types); the umbrella
imports them unchanged after cutover.
- The package surface stays "headless core + rect-registry contract + one
reference form renderer" rather than a broad UI kit. Future richer UI belongs
in host apps, not here.
- If a later workplan needs a genuinely illustrative demo, it goes under an
`examples/` tree, not the package root, so the subsystem boundary stays sharp.

View file

@ -0,0 +1,73 @@
# Extraction inventory & contract deltas (EBIND-WP-0001-T01)
Locks the extraction boundary for pulling `citation-evidence/src/binder/` into
this repository. Authority for the shared contracts remains the umbrella wiki
(`../citation-evidence/wiki/SharedContracts.md`,
`../citation-evidence/wiki/DependencyMap.md`) until the cutover is complete.
## 1. Files that extract into this repo
All of `citation-evidence/src/binder/` moves to `evidence-binder/src/`:
| Concern | Files |
| --- | --- |
| Headless link repo | `repos/in-memory-links.ts`, `repos/index.ts` |
| Binding service | `services/bindings.ts` (+ `.test.ts`), `services/index.ts` |
| Active-state machine | `state/active.ts` (+ `.test.ts`), `state/index.ts` |
| Provider composition | `BinderProvider.tsx` |
| Visual guide (rect registry) | `visual-guide/rect-registry.ts` (+ `.test.ts`), `visual-guide/events.ts`, `visual-guide/react-hooks.ts` (+ `.dom.test.tsx`), `visual-guide/Overlay.tsx` (+ `.dom.test.tsx`), `visual-guide/index.ts` |
| Reference UI | `FormRenderer.tsx` (+ `FormRenderer.dom.test.tsx`), `FieldDefinitionForm.tsx` |
| Barrel | `index.ts` |
The upstream `src/binder/README.md` is **not** carried over — this repo's
top-level `README.md` supersedes it.
## 2. Files that remain umbrella-only
Everything that composes binder with the rest of the app stays in the umbrella
(`citation-evidence/src/app/**`): `FormsApp.tsx`, `HighlightRectBridge.tsx`,
`ActiveEvidenceChips.tsx`, `CaptureLinkPersister.tsx`, `capture-persistence.ts`,
`App.tsx`. These are consumers of the binder, not part of it.
## 3. Import-alias rewrite
Upstream binder files used the umbrella's internal path aliases. On extraction
they are rewritten to the engine's published package specifiers (the same
convention `evidence-anchor` uses):
| Upstream alias | Extracted specifier |
| --- | --- |
| `@shared/evidence-link`, `@shared/ids` | `@citation-evidence/engine/shared` |
| `@engine/events` | `@citation-evidence/engine` |
No source imported `@binder/*`, `@work/*`, `@source/*`, or `@app/*`, so no
forbidden edge existed in the slice. The binder MVP slice imports **only** from
`citation-engine` (shared types + event bus). It does not currently import
`evidence-anchor`, which is an *allowed but unused* edge (DependencyMap §2).
## 4. Contract deltas resolved
- **`EvidenceTarget` naming / target vocabulary.** No drift. The engine's
`EvidenceTarget` (`{ targetType, targetId }`) and the closed
`EvidenceTargetType` catalogue (`form-field`, `claim`, `requirement`,
`decision`, `document-section`) are canonical and consumed as-is. The MVP only
exercises `form-field`. `INTENT.md` previously listed extra target kinds
("tasks, or other information objects"); that is aspirational prose, not a
competing enum, and is left as narrative.
- **Dropped relation values `derived-from` / `needs-check`.** These are **not**
members of the canonical `EvidenceRelation` enum (`supports`, `contradicts`,
`explains`, `qualifies`, `source-for`, `context-for`). The extracted package
does **not** reintroduce them; any earlier INTENT mention of them is dropped
from the implemented surface.
- **Event vocabulary / removal semantics.** The binding service emits only
canonical bus events: `EvidenceLinkCreated`, `EvidenceLinkUpdated`,
`EvidenceLinkRemoved` (hard-delete; rejected-status path deferred), and
`EvidenceItemActivated` / `FormFieldActivated`. All are members of the engine's
`EngineEvent` union — no local event vocabulary is introduced.
## 5. Forbidden dependency edges (called out before copy)
Per DependencyMap §2, `evidence-binder` may depend on `citation-engine` and
`evidence-anchor`, and must **not** depend on `evidence-source`,
`citation-work`, or the umbrella `citation-evidence`. This boundary is enforced
mechanically by `eslint.config.js` (`no-restricted-imports`).

54
eslint.config.js Normal file
View file

@ -0,0 +1,54 @@
// ESLint flat config — enforces the DependencyMap §2 edge set for
// `evidence-binder`: it may depend on `citation-engine` and `evidence-anchor`,
// and must NOT depend on `evidence-source`, `citation-work`, or the umbrella
// `citation-evidence`.
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
// Package specifiers and relative-path prefixes that would cross a forbidden
// dependency edge. Anchor and engine are the only allowed sibling edges.
const FORBIDDEN_EDGES = [
{ name: "@citation-evidence/evidence-source", message: "binder must not depend on evidence-source (DependencyMap §2)." },
{ name: "@citation-evidence/work", message: "binder must not depend on citation-work (DependencyMap §2)." },
{ name: "@citation-evidence/citation-work", message: "binder must not depend on citation-work (DependencyMap §2)." },
];
const FORBIDDEN_PATTERNS = [
{ group: ["../evidence-source/*", "../citation-work/*", "../citation-evidence/*"], message: "binder must not reach into evidence-source, citation-work, or the umbrella (DependencyMap §2)." },
];
export default tseslint.config(
{
ignores: ["dist/", "node_modules/", "coverage/", "**/*.d.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { ...globals.node, ...globals.browser },
},
plugins: {
import: importPlugin,
},
settings: {
"import/resolver": {
typescript: { project: "./tsconfig.json" },
},
},
rules: {
"no-restricted-imports": [
"error",
{
paths: FORBIDDEN_EDGES,
patterns: FORBIDDEN_PATTERNS,
},
],
},
},
);

54
package.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "@citation-evidence/evidence-binder",
"version": "0.1.0",
"private": true,
"description": "Evidence-to-target binding, active-state coordination, and the visual-guide rect-registry contract for the citation-evidence ecosystem.",
"license": "Apache-2.0",
"type": "module",
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=20.10.0"
},
"files": [
"src",
"README.md",
"SCOPE.md",
"INTENT.md",
"LICENSE"
],
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.7.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.30.0",
"globals": "^15.9.0",
"happy-dom": "^20.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.5"
}
}

4006
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
---
repo: evidence-binder
reason: >
Only INTENT/SCOPE/registry/workplans; the binding model is described but not implemented.
reviewed: "2026-07-06"
reviewed_by: claude-code
revisit: >
Revisit once the binding model has a real implementation.
---
# No reusable capability
This repo was reviewed for the `reuse-surface` capability registry
(REUSE-WP-0017 coverage campaign) and has no capability to register at this
time. See the `reason` field above.

View file

@ -1,4 +1,18 @@
version: 1 version: 1
updated: '2026-06-16' updated: '2026-07-08'
domain: helix_forge domain: helix_forge
capabilities: [] capabilities:
- type: library
title: Evidence-to-target binding
description: >-
Links evidence items to structured targets (form fields, claims,
requirements) with relation/status/confidence and two query directions,
emitting canonical engine bus events.
keywords: [evidence, binding, evidence-link, form-field, citation-evidence]
- type: library
title: Visual-guide rect registry
description: >-
The SharedContracts §7 rect-registry contract — independent renderers
publish field/evidence-card/highlight rects into one registry; an SVG
overlay draws the active-triple guide without polling.
keywords: [rect-registry, visual-guide, overlay, active-state, citation-evidence]

119
src/BinderProvider.tsx Normal file
View file

@ -0,0 +1,119 @@
/**
* BinderProvider composition root for the binder subsystem.
*
* Wires the four binder concerns (rect registry, binding service, link
* repo, active state machine) into one provider so a single mount inside
* the EngineProvider gives every binder consumer (FormRenderer, evidence
* picker, SVG overlay) what it needs.
*
* The provider is split out from the engine because in a future
* subsystem-extraction these will live in separate packages the engine
* will publish only the event bus and the engine services, while
* `evidence-binder` will export this provider.
*/
import {
createContext,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import type { EvidenceLink } from "@citation-evidence/engine/shared";
import type { EventBus } from "@citation-evidence/engine";
import {
ActiveStateProvider,
useActiveState,
} from "./state/active";
import {
createInMemoryLinkRepo,
type EvidenceLinkRepository,
} from "./repos/in-memory-links";
import {
createBindingService,
type BindingService,
} from "./services/bindings";
import {
RectRegistryProvider,
createRectRegistryContextValue,
type RectRegistryContextValue,
} from "./visual-guide/react-hooks";
export interface BinderServices {
readonly links: EvidenceLinkRepository;
readonly bindings: BindingService;
readonly rect: RectRegistryContextValue;
}
const BinderServicesContext = createContext<BinderServices | null>(null);
export function useBinder(): BinderServices {
const ctx = useContext(BinderServicesContext);
if (!ctx) throw new Error("useBinder: missing <BinderProvider />");
return ctx;
}
export interface BinderProviderProps {
readonly children: ReactNode;
/**
* The engine's event bus, threaded in by the composition root so the
* binder can emit §4 events without importing work/EngineContext
* (work cannot be a dependency of binder see DependencyMap §2).
*/
readonly bus: EventBus;
/**
* Tests can inject a pre-built service set; production constructs a
* fresh one. The rect registry is *always* fresh per provider mount
* because its observers attach to the current `window`.
*/
readonly services?: Omit<BinderServices, "rect">;
/**
* Restored evidence links for this session. Seeded directly into the
* repo (no bus events) so reload does not spuriously re-emit
* `EvidenceLinkCreated`.
*/
readonly initialLinks?: readonly EvidenceLink[];
}
export function BinderProvider({
children,
bus,
services,
initialLinks,
}: BinderProviderProps) {
const built = useMemo<BinderServices>(() => {
const links = services?.links ?? createInMemoryLinkRepo();
const bindings = services?.bindings ?? createBindingService(links, bus);
const rect = createRectRegistryContextValue();
return { links, bindings, rect };
}, [bus, services]);
useEffect(() => {
if (!initialLinks?.length || services?.links) return;
for (const link of initialLinks) {
if (!built.links.get(link.id)) {
built.links.create(link);
}
}
}, [built.links, initialLinks, services?.links]);
// Disconnect rect observers + listeners on unmount.
useEffect(() => {
return () => {
built.rect.observer.disconnect();
};
}, [built.rect]);
return (
<BinderServicesContext.Provider value={built}>
<RectRegistryProvider value={built.rect}>
<ActiveStateProvider bus={bus}>{children}</ActiveStateProvider>
</RectRegistryProvider>
</BinderServicesContext.Provider>
);
}
export { useActiveState };

117
src/FieldDefinitionForm.tsx Normal file
View file

@ -0,0 +1,117 @@
/**
* Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11).
* Styled to match EvidenceFormBody / InlineCaptureForm.
*/
import type { CSSProperties, ReactNode } from "react";
import type { FormFieldSchema } from "./FormRenderer";
export type FieldType = FormFieldSchema["type"];
const FIELD_TYPES: readonly { value: FieldType; label: string }[] = [
{ value: "text", label: "Text" },
{ value: "textarea", label: "Text area" },
{ value: "date", label: "Date" },
];
export interface FieldDefinitionFormProps {
readonly label: string;
readonly type: FieldType;
onChangeLabel(next: string): void;
onChangeType(next: FieldType): void;
onSave(): void;
onCancel(): void;
readonly saveLabel?: string;
readonly cancelLabel?: string;
readonly badge?: ReactNode;
readonly testidPrefix: string;
}
export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
const saveLabel = p.saveLabel ?? "Save";
const cancelLabel = p.cancelLabel ?? "Cancel";
return (
<div
data-testid={`${p.testidPrefix}-form`}
style={{
border: "1px dashed #b78b1c",
background: "#fff8d6",
marginBottom: 8,
borderRadius: 2,
padding: 8,
fontSize: 12,
}}
>
{p.badge && (
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
)}
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
Field label
</label>
<input
id={`${p.testidPrefix}-label`}
type="text"
value={p.label}
onChange={(e) => p.onChangeLabel(e.target.value)}
data-testid={`${p.testidPrefix}-label-input`}
style={inputStyle}
/>
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
Field type
</label>
<select
id={`${p.testidPrefix}-type`}
value={p.type}
onChange={(e) => p.onChangeType(e.target.value as FieldType)}
data-testid={`${p.testidPrefix}-type-select`}
style={inputStyle}
>
{FIELD_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<button
type="button"
onClick={p.onSave}
data-testid={`${p.testidPrefix}-save`}
style={buttonStyle}
>
{saveLabel}
</button>
<button
type="button"
onClick={p.onCancel}
data-testid={`${p.testidPrefix}-cancel`}
style={buttonStyle}
>
{cancelLabel}
</button>
</div>
</div>
);
}
const labelStyle: CSSProperties = {
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
};
const inputStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
};
const buttonStyle: CSSProperties = {
fontSize: 12,
padding: "4px 10px",
};

View file

@ -0,0 +1,114 @@
/**
* FormRenderer (CE-WP-0003-T04) happy-dom test covering:
* - schema DOM (3 demo fields render with their labels)
* - each field registers with rect registry as kind="field"
* - focusing a field calls activeState.focusTarget and emits FormFieldActivated
* - typing in a field invokes onValueChange
* - linkCounts shows the chip when > 0
*/
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEventBus, type EngineEvent } from "@citation-evidence/engine";
import { FormRenderer, type FormSchema } from "./FormRenderer";
import {
ActiveStateProvider,
} from "./state/active";
import {
RectRegistryProvider,
createRectRegistryContextValue,
} from "./visual-guide/react-hooks";
const SCHEMA: FormSchema = {
id: "demo",
title: "Demo form",
fields: [
{ type: "textarea", id: "summary", label: "Summary" },
{ type: "date", id: "deadline", label: "Deadline" },
{ type: "text", id: "amount", label: "Amount" },
],
};
function renderWithProviders(props: Parameters<typeof FormRenderer>[0]) {
const bus = createEventBus();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
const ctxValue = createRectRegistryContextValue();
const utils = render(
<RectRegistryProvider value={ctxValue}>
<ActiveStateProvider bus={bus}>
<FormRenderer {...props} />
</ActiveStateProvider>
</RectRegistryProvider>,
);
return { ...utils, ctxValue, bus, events };
}
describe("FormRenderer (CE-WP-0003-T04)", () => {
let cleanupCtx: (() => void) | null = null;
beforeEach(() => {
cleanupCtx = null;
});
afterEach(() => {
cleanupCtx?.();
cleanup();
});
it("renders each schema field with its label", () => {
renderWithProviders({ schema: SCHEMA });
expect(screen.getByLabelText("Summary")).toBeTruthy();
expect(screen.getByLabelText("Deadline")).toBeTruthy();
expect(screen.getByLabelText("Amount")).toBeTruthy();
});
it("registers each field with the rect registry as kind=field", () => {
const { ctxValue } = renderWithProviders({ schema: SCHEMA });
cleanupCtx = () => ctxValue.observer.disconnect();
const list = ctxValue.registry.list();
expect(list).toHaveLength(3);
expect(list.every((r) => r.kind === "field")).toBe(true);
expect(list.map((r) => r.id).sort()).toEqual(["amount", "deadline", "summary"]);
});
it("focusing a field emits FormFieldActivated with the right target", async () => {
const user = userEvent.setup();
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
cleanupCtx = () => ctxValue.observer.disconnect();
await user.click(screen.getByLabelText("Summary"));
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
expect(fieldEvents).toHaveLength(1);
expect(fieldEvents[0]).toMatchObject({
target: { targetType: "form-field", targetId: "summary" },
});
});
it("typing forwards onValueChange with the field id + new value", async () => {
const user = userEvent.setup();
const changes: [string, string][] = [];
const { ctxValue } = renderWithProviders({
schema: SCHEMA,
onValueChange: (id, value) => changes.push([id, value]),
});
cleanupCtx = () => ctxValue.observer.disconnect();
await user.type(screen.getByLabelText("Amount"), "42");
expect(changes).toEqual([
["amount", "4"],
["amount", "2"],
]);
});
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
const { ctxValue } = renderWithProviders({
schema: SCHEMA,
linkCounts: { summary: 2, amount: 0 },
});
cleanupCtx = () => ctxValue.observer.disconnect();
expect(screen.queryByTestId("field-summary-chip")).not.toBeNull();
expect(screen.queryByTestId("field-amount-chip")).toBeNull();
});
});

324
src/FormRenderer.tsx Normal file
View file

@ -0,0 +1,324 @@
/**
* FormRenderer renders a FormSchema as a small evidence-backed form.
*
* Each field registers itself with the rect registry under
* `kind="field"` and the field's `id`, so the SVG visual guide (T07) can
* draw curves from the active field to its linked evidence card and on
* to the source highlight.
*
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
*/
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
import { useActiveState, type ActiveState } from "./state/active";
import { useRegisterRect } from "./visual-guide/react-hooks";
function isFieldActive(state: ActiveState, fieldId: string): boolean {
return (
state.activeTarget?.targetType === "form-field" &&
state.activeTarget?.targetId === fieldId
);
}
export interface FormFieldSchema {
readonly type: "text" | "textarea" | "date";
readonly id: string;
readonly label: string;
}
export interface FormSchema {
readonly id: string;
readonly title: string;
readonly fields: readonly FormFieldSchema[];
}
export interface FieldDefinitionPatch {
readonly label: string;
readonly type: FieldType;
}
export interface FormRendererProps {
readonly schema: FormSchema;
readonly values?: Readonly<Record<string, string>>;
readonly onValueChange?: (fieldId: string, value: string) => void;
readonly linkCounts?: Readonly<Record<string, number>>;
readonly linkHints?: Readonly<Record<string, string>>;
readonly showAddFieldForm?: boolean;
readonly onRequestAddField?: () => void;
readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void;
readonly onCancelAddField?: () => void;
readonly editingFieldId?: string | null;
readonly onBeginEditField?: (fieldId: string) => void;
readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void;
readonly onCancelFieldEdit?: () => void;
}
const iconButtonStyle: CSSProperties = {
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
lineHeight: 1,
};
function FieldRow({
field,
value,
linkCount,
linkHint,
isActive,
isEditing,
editLabel,
editType,
onChange,
onFocus,
onBeginEdit,
onChangeEditLabel,
onChangeEditType,
onSaveEdit,
onCancelEdit,
}: {
field: FormFieldSchema;
value: string;
linkCount: number;
linkHint?: string;
isActive: boolean;
isEditing: boolean;
editLabel: string;
editType: FieldType;
onChange: (next: string) => void;
onFocus: () => void;
onBeginEdit: () => void;
onChangeEditLabel: (next: string) => void;
onChangeEditType: (next: FieldType) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useRegisterRect("field", field.id, ref);
if (isEditing) {
return (
<div ref={ref} data-field-id={field.id} style={{ marginBottom: 12 }}>
<FieldDefinitionForm
label={editLabel}
type={editType}
onChangeLabel={onChangeEditLabel}
onChangeType={onChangeEditType}
onSave={onSaveEdit}
onCancel={onCancelEdit}
saveLabel="Save field"
badge="Editing field"
testidPrefix={`field-edit-${field.id}`}
/>
</div>
);
}
const sharedProps = {
id: `field-${field.id}`,
value,
onFocus,
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(e.target.value),
style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 },
};
return (
<div
ref={ref}
data-field-id={field.id}
data-link-count={String(linkCount)}
aria-current={isActive ? "true" : undefined}
style={{
position: "relative",
marginBottom: 12,
fontFamily: "system-ui, sans-serif",
padding: 4,
borderRadius: 4,
background: isActive ? "#e8f0ff" : "transparent",
}}
>
<button
type="button"
aria-label={`Edit field ${field.label}`}
data-testid={`field-edit-toggle-${field.id}`}
title="Edit field label and type"
onClick={(e) => {
e.stopPropagation();
onBeginEdit();
}}
style={{
...iconButtonStyle,
position: "absolute",
top: 4,
right: 4,
zIndex: 1,
}}
>
</button>
<label
htmlFor={sharedProps.id}
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
marginBottom: 4,
paddingRight: 28,
}}
>
{field.label}
{linkCount > 0 ? (
<span
data-testid={`field-${field.id}-chip`}
title={linkHint}
style={{
marginLeft: 8,
padding: "1px 6px",
borderRadius: 4,
background: "#e7f0ff",
color: "#0050b3",
fontSize: 11,
fontWeight: 500,
}}
>
{linkCount} evidence
</span>
) : null}
</label>
{field.type === "textarea" ? (
<textarea rows={2} {...sharedProps} />
) : (
<input type={field.type === "date" ? "date" : "text"} {...sharedProps} />
)}
</div>
);
}
export function FormRenderer({
schema,
values,
onValueChange,
linkCounts,
linkHints,
showAddFieldForm,
onRequestAddField,
onConfirmAddField,
onCancelAddField,
editingFieldId,
onBeginEditField,
onSaveFieldEdit,
onCancelFieldEdit,
}: FormRendererProps) {
const { state, focusTarget } = useActiveState();
const [addLabel, setAddLabel] = useState("New field");
const [addType, setAddType] = useState<FieldType>("text");
const [editLabel, setEditLabel] = useState("");
const [editType, setEditType] = useState<FieldType>("text");
const handleFocus = (fieldId: string) => {
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
focusTarget(target);
};
const beginEdit = (field: FormFieldSchema) => {
setEditLabel(field.label);
setEditType(field.type);
onBeginEditField?.(field.id);
};
return (
<form
data-form-id={schema.id}
style={{ padding: 12 }}
onSubmit={(e) => e.preventDefault()}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 8,
}}
>
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
{schema.title}
</h2>
<button
type="button"
data-testid="add-field-button"
onClick={() => {
setAddLabel(`New field ${schema.fields.length + 1}`);
setAddType("text");
onRequestAddField?.();
}}
style={{
fontSize: 11,
padding: "4px 10px",
border: "1px solid #888",
borderRadius: 4,
background: "white",
cursor: "pointer",
}}
>
Add field
</button>
</div>
{showAddFieldForm && (
<FieldDefinitionForm
label={addLabel}
type={addType}
onChangeLabel={setAddLabel}
onChangeType={setAddType}
onSave={() =>
onConfirmAddField?.({
label: addLabel.trim(),
type: addType,
})
}
onCancel={() => onCancelAddField?.()}
saveLabel="Add field"
badge="New form field"
testidPrefix="field-add"
/>
)}
{schema.fields.map((field) => (
<FieldRow
key={field.id}
field={field}
value={values?.[field.id] ?? ""}
linkCount={linkCounts?.[field.id] ?? 0}
{...(linkHints?.[field.id] != null
? { linkHint: linkHints[field.id] }
: {})}
isActive={isFieldActive(state, field.id)}
isEditing={editingFieldId === field.id}
editLabel={editLabel}
editType={editType}
onChange={(next) => onValueChange?.(field.id, next)}
onFocus={() => handleFocus(field.id)}
onBeginEdit={() => beginEdit(field)}
onChangeEditLabel={setEditLabel}
onChangeEditType={setEditType}
onSaveEdit={() =>
onSaveFieldEdit?.(field.id, {
label: editLabel.trim(),
type: editType,
})
}
onCancelEdit={() => onCancelFieldEdit?.()}
/>
))}
</form>
);
}

12
src/index.ts Normal file
View file

@ -0,0 +1,12 @@
export * from "./repos";
export * from "./services";
export * from "./state";
export * from "./visual-guide";
export { FormRenderer } from "./FormRenderer";
export type {
FormFieldSchema,
FormRendererProps,
FormSchema,
} from "./FormRenderer";
export { BinderProvider, useBinder } from "./BinderProvider";
export type { BinderServices, BinderProviderProps } from "./BinderProvider";

Binary file not shown.

1
src/repos/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./in-memory-links";

View file

@ -0,0 +1,180 @@
/**
* Binding service + in-memory link repo tests.
*
* Exercises every public surface plus the §4 events the service emits.
*/
import { describe, expect, it } from "vitest";
import type {
EvidenceLink,
EvidenceTarget,
} from "@citation-evidence/engine/shared";
import type {
EvidenceItemId,
EvidenceLinkId,
} from "@citation-evidence/engine/shared";
import { createEventBus } from "@citation-evidence/engine";
import type { EngineEvent } from "@citation-evidence/engine";
import { createInMemoryLinkRepo } from "../repos/in-memory-links";
import { createBindingService } from "./bindings";
function makeFixture() {
const bus = createEventBus();
const repo = createInMemoryLinkRepo();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
let counter = 0;
const now = () => `2026-05-25T00:00:0${counter++}.000Z`;
const service = createBindingService(repo, bus, now);
return { bus, repo, events, service };
}
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
const EV1 = "ev_test_one" as EvidenceItemId;
const EV2 = "ev_test_two" as EvidenceItemId;
describe("createBindingService", () => {
it("linkEvidenceToTarget creates a link, emits EvidenceLinkCreated, and persists it", () => {
const { service, repo, events } = makeFixture();
const link = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
});
expect(link.evidenceItemId).toBe(EV1);
expect(link.targetType).toBe("form-field");
expect(link.targetId).toBe("summary");
expect(link.relation).toBe("supports");
expect(link.status).toBe("candidate");
expect(link.createdAt).toBe(link.updatedAt);
expect(repo.get(link.id)).toEqual(link);
const created = events.filter((e) => e.type === "EvidenceLinkCreated");
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ linkId: link.id, link });
});
it("honours explicit relation/status/confidence", () => {
const { service } = makeFixture();
const link = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
relation: "contradicts",
status: "conflicting",
confidence: 0.42,
createdBy: "tegwick",
});
expect(link.relation).toBe("contradicts");
expect(link.status).toBe("conflicting");
expect(link.confidence).toBe(0.42);
expect(link.createdBy).toBe("tegwick");
});
it("listEvidenceForTarget returns only links for the requested target", () => {
const { service } = makeFixture();
const a1 = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_B });
const a2 = service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
const linksForA = service.listEvidenceForTarget(FIELD_A);
expect(linksForA.map((l) => l.id).sort()).toEqual([a1.id, a2.id].sort());
});
it("listTargetsForEvidence returns all targets an evidence item is linked to", () => {
const { service } = makeFixture();
const a = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
const b = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_B });
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
const targets = service.listTargetsForEvidence(EV1);
expect(targets.map((l) => l.id).sort()).toEqual([a.id, b.id].sort());
});
it("unlinkEvidence removes the link and reports success/failure", () => {
const { service } = makeFixture();
const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
expect(service.unlinkEvidence(link.id)).toBe(true);
expect(service.getLink(link.id)).toBeNull();
expect(service.unlinkEvidence(link.id)).toBe(false);
expect(service.unlinkEvidence("evlink_unknown" as EvidenceLinkId)).toBe(false);
});
it("updateLink merges patch, bumps updatedAt, and emits EvidenceLinkUpdated", () => {
const { service, events } = makeFixture();
const original = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
});
const updated = service.updateLink(original.id, {
status: "confirmed",
confidence: 0.9,
});
expect(updated.status).toBe("confirmed");
expect(updated.confidence).toBe(0.9);
expect(updated.relation).toBe(original.relation);
expect(updated.updatedAt).not.toBe(original.updatedAt);
const updatedEvents = events.filter((e) => e.type === "EvidenceLinkUpdated");
expect(updatedEvents).toHaveLength(1);
expect((updatedEvents[0] as Extract<EngineEvent, { type: "EvidenceLinkUpdated" }>).link).toEqual(updated);
});
it("updateLink throws on unknown id", () => {
const { service } = makeFixture();
expect(() =>
service.updateLink("evlink_unknown" as EvidenceLinkId, { status: "verified" }),
).toThrow(/unknown id/);
});
it("setActiveEvidence emits EvidenceItemActivated with source=form-field", () => {
const { service, events } = makeFixture();
service.setActiveEvidence(EV1);
const activated = events.filter((e) => e.type === "EvidenceItemActivated");
expect(activated).toHaveLength(1);
expect(activated[0]).toMatchObject({ evidenceItemId: EV1, source: "form-field" });
});
});
describe("EvidenceLinkRepository (in-memory)", () => {
it("rejects duplicate ids on create", () => {
const repo = createInMemoryLinkRepo();
const link: EvidenceLink = {
id: "evlink_x" as EvidenceLinkId,
evidenceItemId: EV1,
targetType: "form-field",
targetId: "f",
relation: "supports",
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
repo.create(link);
expect(() => repo.create(link)).toThrow(/duplicate/);
});
it("update throws on unknown id", () => {
const repo = createInMemoryLinkRepo();
const link: EvidenceLink = {
id: "evlink_unknown" as EvidenceLinkId,
evidenceItemId: EV1,
targetType: "form-field",
targetId: "f",
relation: "supports",
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
expect(() => repo.update(link)).toThrow(/unknown/);
});
});

118
src/services/bindings.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* Binding service links EvidenceItems to structured targets.
*
* Implements `wiki/ArchitectureOverview.md` §4.6 + SharedContracts §2.4
* (status enum), §2.5 (relation enum). Emits §4 events:
* `EvidenceLinkCreated`, `EvidenceLinkUpdated`, `EvidenceItemActivated`.
*
* MVP semantics:
* - `linkEvidenceToTarget` defaults `relation="supports"`, `status="candidate"`.
* - `unlinkEvidence` is hard-delete; the rejected-status path is left to
* a later ADR.
* - `setActiveEvidence` emits an `EvidenceItemActivated` event with
* `source="form-field"` so the viewer/sidebar can react.
*/
import type {
EvidenceLink,
EvidenceLinkStoredStatus,
EvidenceRelation,
EvidenceTarget,
} from "@citation-evidence/engine/shared";
import type { EvidenceItemId, EvidenceLinkId } from "@citation-evidence/engine/shared";
import { newId } from "@citation-evidence/engine/shared";
import type { EventBus } from "@citation-evidence/engine";
import type { EvidenceLinkRepository } from "../repos/in-memory-links";
export interface LinkEvidenceToTargetInput {
readonly evidenceItemId: EvidenceItemId;
readonly target: EvidenceTarget;
readonly relation?: EvidenceRelation;
readonly status?: EvidenceLinkStoredStatus;
readonly confidence?: number;
readonly createdBy?: string;
}
export interface UpdateLinkStatusInput {
readonly status?: EvidenceLinkStoredStatus;
readonly relation?: EvidenceRelation;
readonly confidence?: number;
}
export interface BindingService {
linkEvidenceToTarget(input: LinkEvidenceToTargetInput): EvidenceLink;
unlinkEvidence(id: EvidenceLinkId): boolean;
updateLink(id: EvidenceLinkId, input: UpdateLinkStatusInput): EvidenceLink;
getLink(id: EvidenceLinkId): EvidenceLink | null;
listEvidenceForTarget(target: EvidenceTarget): readonly EvidenceLink[];
listTargetsForEvidence(evidenceItemId: EvidenceItemId): readonly EvidenceLink[];
setActiveEvidence(evidenceItemId: EvidenceItemId): void;
}
export function createBindingService(
links: EvidenceLinkRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): BindingService {
return {
linkEvidenceToTarget(input) {
const ts = now();
const link: EvidenceLink = {
id: newId("evidence-link"),
evidenceItemId: input.evidenceItemId,
targetType: input.target.targetType,
targetId: input.target.targetId,
relation: input.relation ?? "supports",
status: input.status ?? "candidate",
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = links.create(link);
bus.emit({ type: "EvidenceLinkCreated", linkId: stored.id, link: stored });
return stored;
},
unlinkEvidence(id) {
const removed = links.delete(id);
if (removed) {
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
}
return removed;
},
updateLink(id, input) {
const existing = links.get(id);
if (!existing) {
throw new Error(`BindingService.updateLink: unknown id ${id}`);
}
const next: EvidenceLink = {
...existing,
...(input.status !== undefined ? { status: input.status } : {}),
...(input.relation !== undefined ? { relation: input.relation } : {}),
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
updatedAt: now(),
};
const stored = links.update(next);
bus.emit({ type: "EvidenceLinkUpdated", linkId: stored.id, link: stored });
return stored;
},
getLink(id) {
return links.get(id);
},
listEvidenceForTarget(target) {
return links.listForTarget(target);
},
listTargetsForEvidence(evidenceItemId) {
return links.listForEvidenceItem(evidenceItemId);
},
setActiveEvidence(evidenceItemId) {
bus.emit({
type: "EvidenceItemActivated",
evidenceItemId,
source: "form-field",
});
},
};
}

1
src/services/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./bindings";

64
src/state/active.test.ts Normal file
View file

@ -0,0 +1,64 @@
/**
* Reducer-level tests for the active-state machine.
*
* React-level Provider/hook tests live with the integration suites.
*/
import { describe, expect, it } from "vitest";
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
import { __test } from "./active";
const { reducer, EMPTY_ACTIVE_STATE } = __test;
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
const EV1 = "ev_one" as EvidenceItemId;
const EV2 = "ev_two" as EvidenceItemId;
const ANN1 = "ann_one" as AnnotationId;
describe("ActiveState reducer", () => {
it("focus-target sets activeTarget and clears active evidence", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const withEv = reducer(seeded, {
type: "set-active-evidence",
evidenceItemId: EV1,
annotationId: ANN1,
});
const refocused = reducer(withEv, { type: "focus-target", target: FIELD_B });
expect(refocused.activeTarget).toEqual(FIELD_B);
expect(refocused.activeEvidenceItemId).toBeNull();
expect(refocused.activeAnnotationId).toBeNull();
});
it("focus-target on the same target is a no-op (preserves identity)", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const withEv = reducer(seeded, {
type: "set-active-evidence",
evidenceItemId: EV1,
annotationId: ANN1,
});
const sameAgain = reducer(withEv, { type: "focus-target", target: { ...FIELD_A } });
expect(sameAgain).toBe(withEv);
});
it("set-active-evidence updates evidence + annotation without touching target", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const next = reducer(seeded, {
type: "set-active-evidence",
evidenceItemId: EV2,
annotationId: null,
});
expect(next.activeTarget).toEqual(FIELD_A);
expect(next.activeEvidenceItemId).toBe(EV2);
expect(next.activeAnnotationId).toBeNull();
});
it("clear returns to the empty state", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const cleared = reducer(seeded, { type: "clear" });
expect(cleared).toEqual(EMPTY_ACTIVE_STATE);
});
});

183
src/state/active.ts Normal file
View file

@ -0,0 +1,183 @@
/**
* Active state machine + React context for the form-binding flow.
*
* Tracks the `(activeTarget, activeEvidenceItemId, activeAnnotationId)`
* triple that the SVG visual guide and the viewer adapter both depend on.
*
* Transitions:
* - `focusTarget(target)` clears the active evidence, emits
* `FormFieldActivated`.
* - `setActiveEvidence(evidenceItemId, annotationId?)` sets active
* evidence (and optionally the active annotation derived from it),
* emits `EvidenceItemActivated` with `source="form-field"`. The
* binding-service helper does the same; the state machine owns the
* React-facing source of truth.
* - `clear()` drops everything back to undefined.
*
* The state itself is a small immutable record (so React equality checks
* stay simple). All mutations go through a single reducer.
*/
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
type ReactNode,
} from "react";
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
import type { EventBus } from "@citation-evidence/engine";
export interface ActiveState {
readonly activeTarget: EvidenceTarget | null;
readonly activeEvidenceItemId: EvidenceItemId | null;
readonly activeAnnotationId: AnnotationId | null;
}
export const EMPTY_ACTIVE_STATE: ActiveState = {
activeTarget: null,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
type Action =
| { type: "focus-target"; target: EvidenceTarget }
| {
type: "set-active-evidence";
evidenceItemId: EvidenceItemId;
annotationId: AnnotationId | null;
}
| { type: "clear-active-evidence" }
| { type: "clear" };
function reducer(state: ActiveState, action: Action): ActiveState {
switch (action.type) {
case "focus-target":
// Focusing a target resets the active evidence — a different field
// means a different evidence set.
if (
state.activeTarget?.targetType === action.target.targetType &&
state.activeTarget?.targetId === action.target.targetId
) {
return state;
}
return {
activeTarget: action.target,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
case "set-active-evidence":
return {
activeTarget: state.activeTarget,
activeEvidenceItemId: action.evidenceItemId,
activeAnnotationId: action.annotationId,
};
case "clear-active-evidence":
return {
activeTarget: state.activeTarget,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
case "clear":
return EMPTY_ACTIVE_STATE;
}
}
export interface ActiveStateApi {
readonly state: ActiveState;
focusTarget(target: EvidenceTarget): void;
setActiveEvidence(
evidenceItemId: EvidenceItemId,
annotationId?: AnnotationId | null,
): void;
clearActiveEvidence(): void;
clear(): void;
}
const ActiveStateContext = createContext<ActiveStateApi | null>(null);
export interface ActiveStateProviderProps {
readonly bus: EventBus;
readonly children: ReactNode;
}
/**
* React provider for the binder's active-state machine. Mounts inside the
* EngineProvider so it can wire `bus` from the engine.
*/
export function ActiveStateProvider(props: ActiveStateProviderProps) {
const [state, dispatch] = useReducer(reducer, EMPTY_ACTIVE_STATE);
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const focusTarget = useCallback(
(target: EvidenceTarget) => {
const previousTarget = stateRef.current.activeTarget;
const samePrevious =
previousTarget?.targetType === target.targetType &&
previousTarget?.targetId === target.targetId;
if (samePrevious) return;
props.bus.emit({
type: "FormFieldActivated",
target,
...(previousTarget !== null ? { previousTarget } : {}),
});
dispatch({ type: "focus-target", target });
},
[props.bus],
);
const setActiveEvidence = useCallback(
(evidenceItemId: EvidenceItemId, annotationId?: AnnotationId | null) => {
props.bus.emit({
type: "EvidenceItemActivated",
evidenceItemId,
source: "form-field",
});
dispatch({
type: "set-active-evidence",
evidenceItemId,
annotationId: annotationId ?? null,
});
},
[props.bus],
);
const clearActiveEvidence = useCallback(() => {
dispatch({ type: "clear-active-evidence" });
}, []);
const clear = useCallback(() => {
dispatch({ type: "clear" });
}, []);
const value = useMemo<ActiveStateApi>(
() => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
[state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
);
return createElement(ActiveStateContext.Provider, { value }, props.children);
}
export function useActiveState(): ActiveStateApi {
const ctx = useContext(ActiveStateContext);
if (!ctx) {
throw new Error("useActiveState must be used inside <ActiveStateProvider />");
}
return ctx;
}
/**
* Pure reducer + initial state, exported so the headless tests can verify
* transitions without spinning up React.
*/
export const __test = { reducer, EMPTY_ACTIVE_STATE };

1
src/state/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./active";

View file

@ -0,0 +1,150 @@
/**
* Overlay unit test (CE-WP-0003-T07).
*
* Verifies the SVG renders the right number of paths given the active
* triple state and registered rects. Curve geometry is not asserted
* the bezier helper is intentionally simple and changes will be caught
* by visual review, not test maintenance.
*/
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEventBus } from "@citation-evidence/engine";
import { Overlay } from "./Overlay";
import { ActiveStateProvider, useActiveState } from "../state/active";
import {
RectRegistryProvider,
createRectRegistryContextValue,
type RectRegistryContextValue,
} from "./react-hooks";
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
return {
x, y, width: w, height: h,
top: y, left: x, right: x + w, bottom: y + h,
toJSON() { return { x, y, width: w, height: h }; },
} as DOMRect;
}
const FIELD: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const EV_ID = "ev_one" as EvidenceItemId;
const ANN_ID = "ann_one" as AnnotationId;
// Tiny harness to drive the binder's active-state from outside the
// provider tree (so the test can stage state without a long click path).
function Driver({ onActive }: { onActive: (api: ReturnType<typeof useActiveState>) => void }) {
const api = useActiveState();
onActive(api);
return null;
}
describe("Overlay (CE-WP-0003-T07)", () => {
let ctx: RectRegistryContextValue;
beforeEach(() => {
ctx = createRectRegistryContextValue();
});
afterEach(() => {
ctx.observer.disconnect();
cleanup();
});
it("renders nothing when no triple is active", () => {
const bus = createEventBus();
const { container } = render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
expect(container.querySelector("svg")).toBeNull();
});
it("draws one path when only field + card rects are registered", async () => {
const bus = createEventBus();
let api: ReturnType<typeof useActiveState> | null = null;
render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Driver onActive={(a) => (api = a)} />
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
// Register the two known rects.
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
// Activate the triple. annotationId left null so no highlight is queried.
await act(async () => {
api!.focusTarget(FIELD);
api!.setActiveEvidence(EV_ID, null);
});
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
expect(svg).not.toBeNull();
expect(svg.getAttribute("data-path-count")).toBe("1");
});
it("draws two paths when field + card + highlight rects are all registered", async () => {
const bus = createEventBus();
let api: ReturnType<typeof useActiveState> | null = null;
render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Driver onActive={(a) => (api = a)} />
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
ctx.registry.register("highlight", ANN_ID, () => fakeRect(700, 400, 200, 20));
await act(async () => {
api!.focusTarget(FIELD);
api!.setActiveEvidence(EV_ID, ANN_ID);
});
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
expect(svg.getAttribute("data-path-count")).toBe("2");
expect(svg.querySelectorAll("path").length).toBe(2);
});
it("re-renders when the registry invalidates after rect changes", async () => {
const bus = createEventBus();
let api: ReturnType<typeof useActiveState> | null = null;
render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Driver onActive={(a) => (api = a)} />
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
ctx.registry.register("field", FIELD.targetId, () => fakeRect(0, 0, 10, 10));
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(100, 100, 10, 10));
await act(async () => {
api!.focusTarget(FIELD);
api!.setActiveEvidence(EV_ID, null);
});
const d1 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
// Mutate one of the getters' results, then invalidate.
ctx.registry.register("field", FIELD.targetId, () => fakeRect(500, 500, 10, 10));
await act(async () => {
ctx.registry.invalidate();
});
const d2 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
expect(d1).not.toBe(d2);
});
});

View file

@ -0,0 +1,123 @@
/**
* Visual-guide overlay draws curves between the active triple.
*
* Subscribes to the rect registry + active-state machine and redraws a
* pair of bezier curves on every rect-change event:
*
* field evidence-card highlight
*
* Throttling: `attachRectChangePumps` already coalesces scroll/resize
* bursts into one `rect-changed` per animation frame. The overlay's
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
* up that single tick and React re-renders once per frame.
*
* Active-only: only the currently active triple is drawn. If any leg's
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
* the active annotation yet), that leg is omitted but the other one
* still renders.
*
* MVP-sufficient. Future polish: easing the curve direction by source
* type, animating the transition between active states, dimming
* non-active rects rather than hiding them.
*/
import { useMemo } from "react";
import { useActiveState } from "../state/active";
import {
useRectRegistryContext,
useRectRegistryVersion,
} from "./react-hooks";
function rectCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.bottom };
}
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top };
}
/**
* Build a quadratic bezier from `a` to `b` whose control point bulges
* horizontally between them. The horizontal-bulge style is right for a
* left-panecentre-paneright-pane layout; vertical-bulge can be added
* later when we have a layout that needs it.
*/
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
const dx = b.x - a.x;
const cpx = a.x + dx / 2;
return `M ${a.x} ${a.y} Q ${cpx} ${a.y} ${(a.x + b.x) / 2} ${(a.y + b.y) / 2} T ${b.x} ${b.y}`;
}
export interface OverlayProps {
/** Curve stroke colour. Defaults to the engine's accent blue. */
readonly strokeColor?: string;
/** Curve stroke width. Defaults to 2px. */
readonly strokeWidth?: number;
/** Optional className for styling hooks; the inline styles cover layout. */
readonly className?: string;
}
export function Overlay({
strokeColor = "#999",
strokeWidth = 1,
className,
}: OverlayProps = {}) {
const { state } = useActiveState();
const { registry } = useRectRegistryContext();
const version = useRectRegistryVersion();
const paths = useMemo<readonly string[]>(() => {
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
const highlightRect = state.activeAnnotationId
? registry.getRect("highlight", state.activeAnnotationId)
: null;
const out: string[] = [];
if (fieldRect && cardRect) {
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
}
if (cardRect && highlightRect) {
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
}
void version; // memo invalidator
return out;
}, [state, registry, version]);
if (paths.length === 0) return null;
return (
<svg
data-testid="visual-guide-overlay"
data-active-target={state.activeTarget?.targetId ?? ""}
data-active-evidence={state.activeEvidenceItemId ?? ""}
data-path-count={String(paths.length)}
className={className}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
pointerEvents: "none",
zIndex: 9999,
}}
>
{paths.map((d, i) => (
<path
key={i}
d={d}
stroke={strokeColor}
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
/>
))}
</svg>
);
}

118
src/visual-guide/events.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* Browser-level rect-change pumps.
*
* The rect registry holds `getRect` callbacks but doesn't observe the DOM
* itself. This module wires the four global change sources from
* `wiki/SharedContracts.md` §7 ("scroll, resize, focus, and
* active-evidence change") into a single `registry.invalidate()` call.
*
* Active-evidence change is fired imperatively by the binder service when
* it calls `setActiveEvidence` see `services/bindings.ts`.
*
* SSR-safe: every API checks `typeof window !== "undefined"` and is a
* no-op when the DOM isn't available, so tests that import this module
* under Node never crash.
*/
import type { RectRegistry } from "./rect-registry";
export interface RectChangeObserverOptions {
/**
* Throttle invalidations to a single requestAnimationFrame; otherwise a
* fast scroll event burst causes the overlay to redraw on every pixel.
* Defaults to true. Tests pass `false` for deterministic synchronous
* behaviour.
*/
readonly throttle?: boolean;
}
export interface RectChangeObserverHandle {
/**
* Begin watching a DOM element. The registry is notified of any
* scroll/resize/focus event that bubbles to the ancestor chain or fires
* on the element itself. Returns a cleanup that stops watching.
*/
observe(element: Element): () => void;
/** Tear down all observers + global listeners. */
disconnect(): void;
}
/**
* Attach scroll/resize/focus pumps to the given registry. Returns an
* observer handle so per-element ResizeObservers can be cleaned up by
* the components that registered them.
*/
export function attachRectChangePumps(
registry: RectRegistry,
options: RectChangeObserverOptions = {},
): RectChangeObserverHandle {
const throttle = options.throttle ?? true;
if (typeof window === "undefined") {
return {
observe: () => () => {},
disconnect: () => {},
};
}
let pending = false;
function invalidate() {
if (!throttle) {
registry.invalidate();
return;
}
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
registry.invalidate();
});
}
const onScroll = invalidate;
const onResize = invalidate;
const onFocusIn = invalidate;
// capture-phase scroll catches scrolling in any nested scroll container,
// not just the document — needed for the PDF viewer's inner scroller.
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
window.addEventListener("resize", onResize, { passive: true });
document.addEventListener("focusin", onFocusIn);
// One global ResizeObserver shared across observed elements is cheaper
// than per-element observers but loses the per-element resolution; we
// don't need per-element resolution because invalidations are global.
const ro: ResizeObserver | null =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(invalidate) : null;
// IntersectionObserver fires when an element moves into/out of the
// viewport — useful for the highlight which may scroll off-screen.
const io: IntersectionObserver | null =
typeof IntersectionObserver !== "undefined"
? new IntersectionObserver(invalidate, { threshold: [0, 1] })
: null;
const observedElements = new Set<Element>();
return {
observe(element) {
observedElements.add(element);
ro?.observe(element);
io?.observe(element);
return () => {
observedElements.delete(element);
ro?.unobserve(element);
io?.unobserve(element);
};
},
disconnect() {
window.removeEventListener("scroll", onScroll, { capture: true } as EventListenerOptions);
window.removeEventListener("resize", onResize);
document.removeEventListener("focusin", onFocusIn);
ro?.disconnect();
io?.disconnect();
observedElements.clear();
},
};
}

View file

@ -0,0 +1,4 @@
export * from "./rect-registry";
export * from "./events";
export * from "./react-hooks";
export { Overlay, type OverlayProps } from "./Overlay";

View file

@ -0,0 +1,152 @@
/**
* happy-dom-level test for the rect registry React hooks.
*
* Verifies the full §7 contract under realistic conditions:
* - useRegisterRect attaches a getRect callback bound to the
* element's getBoundingClientRect
* - mutating the element's rect produces fresh values via getRect
* - scroll/resize events on window fan out to a registry invalidate
* - useRectRegistryVersion bumps each time the registry emits
*/
// @vitest-environment happy-dom
import { act, render } from "@testing-library/react";
import { useRef } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
RectRegistryProvider,
createRectRegistryContextValue,
useRectRegistryContext,
useRectRegistryVersion,
useRegisterRect,
} from "./react-hooks";
import type { RectRegistryEvent } from "./rect-registry";
function FieldUnderTest({
id,
onVersion,
}: {
id: string;
onVersion?: (v: number) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useRegisterRect("field", id, ref);
const version = useRectRegistryVersion();
onVersion?.(version);
return <div ref={ref} data-testid={`f-${id}`} />;
}
function CtxSpy({ onCtx }: { onCtx: (registry: ReturnType<typeof useRectRegistryContext>) => void }) {
const ctx = useRectRegistryContext();
onCtx(ctx);
return null;
}
describe("useRegisterRect (happy-dom)", () => {
let ctxValue: ReturnType<typeof createRectRegistryContextValue>;
beforeEach(() => {
ctxValue = createRectRegistryContextValue();
});
afterEach(() => {
ctxValue.observer.disconnect();
});
it("registers the element's getBoundingClientRect and unregisters on unmount", () => {
const events: RectRegistryEvent[] = [];
ctxValue.registry.subscribe((e) => events.push(e));
const { unmount } = render(
<RectRegistryProvider value={ctxValue}>
<FieldUnderTest id="summary" />
</RectRegistryProvider>,
);
expect(ctxValue.registry.getRect("field", "summary")).not.toBeNull();
expect(ctxValue.registry.list()).toEqual([{ kind: "field", id: "summary" }]);
unmount();
expect(ctxValue.registry.getRect("field", "summary")).toBeNull();
expect(events.map((e) => e.type)).toContain("unregistered");
});
it("getRect reflects mutated bounding rects", () => {
let getter: () => DOMRect | null = () => null;
// Spy on the registered callback by hijacking register
const realRegister = ctxValue.registry.register;
ctxValue.registry.register = (kind, id, fn) => {
getter = fn;
return realRegister.call(ctxValue.registry, kind, id, fn);
};
render(
<RectRegistryProvider value={ctxValue}>
<FieldUnderTest id="amount" />
</RectRegistryProvider>,
);
// happy-dom returns a DOMRect with all zeros by default. Patch the
// element's getBoundingClientRect and verify the registered callback
// forwards the new rect.
const el = document.querySelector('[data-testid="f-amount"]') as HTMLDivElement;
el.getBoundingClientRect = () => ({
x: 11,
y: 22,
width: 33,
height: 44,
top: 22,
left: 11,
right: 11 + 33,
bottom: 22 + 44,
toJSON() {
return {};
},
});
const rect = getter();
expect(rect).not.toBeNull();
expect(rect!.x).toBe(11);
expect(rect!.width).toBe(33);
});
it("useRectRegistryVersion bumps on register and on invalidate", async () => {
const seen: number[] = [];
const renderResult = render(
<RectRegistryProvider value={ctxValue}>
<FieldUnderTest
id="bumpy"
onVersion={(v) => seen.push(v)}
/>
</RectRegistryProvider>,
);
// Wait one microtask for effects to flush.
await act(async () => {});
const beforeInvalidate = seen[seen.length - 1]!;
await act(async () => {
ctxValue.registry.invalidate();
});
const afterInvalidate = seen[seen.length - 1]!;
expect(afterInvalidate).toBeGreaterThan(beforeInvalidate);
renderResult.unmount();
});
it("exposes the same registry across consumers in the provider subtree", () => {
let firstCtx: ReturnType<typeof useRectRegistryContext> | undefined;
let secondCtx: ReturnType<typeof useRectRegistryContext> | undefined;
render(
<RectRegistryProvider value={ctxValue}>
<CtxSpy onCtx={(c) => (firstCtx = c)} />
<CtxSpy onCtx={(c) => (secondCtx = c)} />
</RectRegistryProvider>,
);
expect(firstCtx).toBe(secondCtx);
expect(firstCtx?.registry).toBe(ctxValue.registry);
});
});

View file

@ -0,0 +1,98 @@
/**
* React hooks for the rect registry.
*
* Components mount, get a ref to a DOM node, and ask the registry to
* track it via `useRegisterRect(kind, id, ref)`. Unmount/ref-change
* unregisters automatically.
*
* The registry itself lives behind a React context so multiple subtrees
* can share one registry (the overlay sees what every renderer publishes).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useSyncExternalStore,
type RefObject,
} from "react";
import {
createRectRegistry,
type RectKind,
type RectRegistry,
} from "./rect-registry";
import { attachRectChangePumps, type RectChangeObserverHandle } from "./events";
export interface RectRegistryContextValue {
readonly registry: RectRegistry;
readonly observer: RectChangeObserverHandle;
}
const RectRegistryContext = createContext<RectRegistryContextValue | null>(null);
/**
* Create an isolated registry + change pump pair for tests or app
* composition roots that wire their own provider.
*/
export function createRectRegistryContextValue(): RectRegistryContextValue {
const registry = createRectRegistry();
const observer = attachRectChangePumps(registry);
return { registry, observer };
}
export function useRectRegistryContext(): RectRegistryContextValue {
const ctx = useContext(RectRegistryContext);
if (!ctx) {
throw new Error(
"useRectRegistryContext must be used inside <RectRegistryProvider />",
);
}
return ctx;
}
export const RectRegistryProvider = RectRegistryContext.Provider;
/**
* Register a DOM ref's bounding rect with the registry.
*
* Re-runs when `kind`/`id`/`ref.current` change. The observer also starts
* watching the element for scroll/resize so the overlay can re-query
* without polling.
*/
export function useRegisterRect(
kind: RectKind,
id: string,
ref: RefObject<Element | null>,
): void {
const { registry, observer } = useRectRegistryContext();
useEffect(() => {
const el = ref.current;
if (!el) return;
const unregister = registry.register(kind, id, () => el.getBoundingClientRect());
const unobserve = observer.observe(el);
return () => {
unobserve();
unregister();
};
}, [kind, id, ref, registry, observer]);
}
/**
* Subscribe to registry change events from inside React. Returns a
* monotonically-increasing version number that bumps on every event, so
* `useMemo`/`useEffect` deps can include it to re-derive cached values.
*
* Implementation: leans on `registry.getVersion()` for the snapshot so
* `useSyncExternalStore` doesn't accumulate per-render subscribers.
*/
export function useRectRegistryVersion(): number {
const { registry } = useRectRegistryContext();
const subscribe = useCallback(
(callback: () => void) => registry.subscribe(callback),
[registry],
);
const getSnapshot = useCallback(() => registry.getVersion(), [registry]);
return useSyncExternalStore(subscribe, getSnapshot, () => 0);
}

View file

@ -0,0 +1,151 @@
/**
* Rect registry unit tests exercise every public surface plus the
* §7-contract guarantees:
* - register/unregister fire subscriber events
* - getRect returns the live result of the registered callback
* - invalidate fires a global `rect-changed` event
* - version bumps on every emit
* - re-registering the same (kind,id) supersedes the prior callback;
* the stale unregister cleanup does not delete the new entry.
*/
import { describe, expect, it } from "vitest";
import {
createRectRegistry,
type RectRegistryEvent,
} from "./rect-registry";
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
// happy-dom/jsdom isn't loaded for this test — synth a DOMRect-shaped
// object. The registry contract only reads these properties.
return {
x,
y,
width: w,
height: h,
top: y,
left: x,
right: x + w,
bottom: y + h,
toJSON() {
return { x, y, width: w, height: h };
},
} as DOMRect;
}
describe("createRectRegistry", () => {
it("returns null for unknown rects", () => {
const r = createRectRegistry();
expect(r.getRect("field", "missing")).toBeNull();
});
it("register/getRect roundtrip", () => {
const r = createRectRegistry();
r.register("field", "f1", () => fakeRect(1, 2, 3, 4));
const rect = r.getRect("field", "f1");
expect(rect).not.toBeNull();
expect(rect!.x).toBe(1);
expect(rect!.width).toBe(3);
});
it("getRect reflects live callback results", () => {
const r = createRectRegistry();
let xPos = 10;
r.register("highlight", "h1", () => fakeRect(xPos, 0, 5, 5));
expect(r.getRect("highlight", "h1")!.x).toBe(10);
xPos = 200;
expect(r.getRect("highlight", "h1")!.x).toBe(200);
});
it("returns null when the callback throws", () => {
const r = createRectRegistry();
r.register("field", "boom", () => {
throw new Error("nope");
});
expect(r.getRect("field", "boom")).toBeNull();
});
it("emits registered + unregistered events", () => {
const r = createRectRegistry();
const events: RectRegistryEvent[] = [];
r.subscribe((e) => events.push(e));
const unregister = r.register("evidence-card", "ev1", () => fakeRect(0, 0, 1, 1));
unregister();
expect(events).toEqual([
{ type: "registered", kind: "evidence-card", id: "ev1" },
{ type: "unregistered", kind: "evidence-card", id: "ev1" },
]);
});
it("invalidate emits a global rect-changed event and bumps version", () => {
const r = createRectRegistry();
const events: RectRegistryEvent[] = [];
r.subscribe((e) => events.push(e));
const before = r.getVersion();
r.invalidate();
expect(events).toEqual([{ type: "rect-changed" }]);
expect(r.getVersion()).toBe(before + 1);
});
it("re-registering the same (kind,id) supersedes; stale cleanup is a no-op", () => {
const r = createRectRegistry();
const events: RectRegistryEvent[] = [];
r.subscribe((e) => events.push(e));
const firstGetRect = () => fakeRect(1, 1, 1, 1);
const secondGetRect = () => fakeRect(9, 9, 9, 9);
const cleanup1 = r.register("highlight", "x", firstGetRect);
r.register("highlight", "x", secondGetRect); // supersede
// The stale cleanup must not remove the new registration.
cleanup1();
expect(r.getRect("highlight", "x")!.x).toBe(9);
// Two `registered` events, no `unregistered` event — the second
// register overwrote without an explicit unregister, and the stale
// cleanup detected the (kind,id) holds a different callback.
expect(events.filter((e) => e.type === "unregistered")).toHaveLength(0);
expect(events.filter((e) => e.type === "registered")).toHaveLength(2);
});
it("subscribe returns an unsubscribe that detaches the listener", () => {
const r = createRectRegistry();
let count = 0;
const off = r.subscribe(() => count++);
r.invalidate();
off();
r.invalidate();
expect(count).toBe(1);
});
it("listener errors do not break sibling listeners", () => {
const r = createRectRegistry();
let okCount = 0;
r.subscribe(() => {
throw new Error("boom");
});
r.subscribe(() => {
okCount++;
});
r.invalidate();
expect(okCount).toBe(1);
});
it("list enumerates current registrations", () => {
const r = createRectRegistry();
r.register("field", "f1", () => null);
r.register("evidence-card", "ev1", () => null);
r.register("highlight", "h1", () => null);
const list = r.list();
expect(list).toHaveLength(3);
expect(list).toEqual(
expect.arrayContaining([
{ kind: "field", id: "f1" },
{ kind: "evidence-card", id: "ev1" },
{ kind: "highlight", id: "h1" },
]),
);
});
});

Binary file not shown.

30
tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["../citation-engine/src/shared/*"],
"@engine/*": ["../citation-engine/src/engine/*"]
}
},
"include": ["src", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

34
vitest.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
// The binder splits into a pure headless core (bindings, active-state reducer,
// rect-registry) that is node-safe, and a React surface (provider, form
// renderer, overlay, registry hooks) exercised in a DOM. Tests whose filename
// ends in `.dom.test.{ts,tsx}` run under happy-dom; everything else runs in
// Node, which is faster. The react plugin lets the `.tsx` sources compile.
//
// The one runtime dependency, `@citation-evidence/engine`, resolves through its
// package `exports` map (`.` and `./shared`) via the sibling-checkout link. The
// engine ships raw TypeScript that uses its own `@shared/*` / `@engine/*` path
// aliases internally; tsc honours those via tsconfig `paths`, but Vite does
// not read tsconfig, so we mirror them here as resolve aliases. Nothing in this
// package imports `citation-evidence` internals — the aliases only exist so the
// linked engine source resolves.
const enginePath = (rel: string) =>
fileURLToPath(new URL(`../citation-engine/src/${rel}`, import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: [
{ find: /^@shared\//, replacement: `${enginePath("shared")}/` },
{ find: /^@engine\//, replacement: `${enginePath("engine")}/` },
],
},
test: {
environmentMatchGlobs: [["**/*.dom.test.{ts,tsx}", "happy-dom"]],
globals: false,
},
});

View file

@ -4,97 +4,149 @@ type: workplan
title: "Extract evidence-binder from citation-evidence" title: "Extract evidence-binder from citation-evidence"
domain: infotech domain: infotech
repo: evidence-binder repo: evidence-binder
status: ready status: done
owner: codex owner: codex
topic_slug: citation_evidence_mvp topic_slug: citation_evidence_mvp
created: "2026-06-21" created: "2026-06-21"
updated: "2026-07-08" updated: "2026-07-09"
state_hub_workstream_id: "405d789c-0e0b-4764-89ed-0f6140a143e7" state_hub_workstream_id: "405d789c-0e0b-4764-89ed-0f6140a143e7"
--- ---
# EBIND-WP-0001 — Extract evidence-binder from citation-evidence # EBIND-WP-0001 — Extract evidence-binder from citation-evidence
`evidence-binder` is still INTENT-only in this repo, but the current Extract `../citation-evidence/src/binder/` into this repository as a standalone
implementation already exists upstream in `../citation-evidence/src/binder/`. package without changing the binder's canonical responsibilities: evidence
That slice includes a binding service, in-memory link repository, active-state links, active target/evidence state, and the visual-guide rect registry.
machine, rect registry and overlay, provider wiring, and a small form-oriented
reference UI. This workplan turns the placeholder into a concrete extraction
plan that preserves the canonical contracts in
`../citation-evidence/wiki/SharedContracts.md` and the allowed edges in
`../citation-evidence/wiki/DependencyMap.md`.
## Lock Extraction Boundary And Contract Deltas The extracted package must conform to
`../citation-evidence/wiki/SharedContracts.md`, respect
`../citation-evidence/wiki/DependencyMap.md`, and remain dependent on
`citation-engine` and `evidence-anchor` only.
Prerequisites:
- umbrella MVP binder slice already exists in `../citation-evidence/src/binder/`
- `citation-engine` is already extracted and available as a sibling package
- shared contract authority remains in the umbrella docs until binder extraction
is complete
## Goals
1. This repo becomes the canonical home of the binder implementation.
2. Umbrella behavior remains unchanged after wireup.
3. The extracted package stays target-neutral and does not pick up forbidden
`citation-work` or `evidence-source` dependencies.
4. Tests, lint, and typecheck are green in both this repo and the umbrella
consumer after cutover.
## Dependency Order
```text
T01 (boundary + contract lock)
└─ T02 (toolchain + package scaffold)
├─ T03 (headless bindings + provider extraction)
│ └─ T04 (visual guide extraction)
├─ T05 (reference UI surface decision)
└─ T06 (docs + local contract references)
└─ T07 (umbrella wireup)
└─ T08 (verification + closeout)
```
---
## T01 — Lock extraction boundary and contract deltas
```task ```task
id: EBIND-WP-0001-T01 id: EBIND-WP-0001-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "f2abe7f8-089b-4719-a739-18dc3224e9eb" state_hub_task_id: "f2abe7f8-089b-4719-a739-18dc3224e9eb"
``` ```
Review the upstream binder slice (`services/`, `repos/`, `state/`, Review the upstream binder slice against the current shared contracts before any
`visual-guide/`, `BinderProvider.tsx`, `FormRenderer.tsx`) against code moves:
`SharedContracts.md` sections 2.4, 2.5, 4, and 7 plus `DependencyMap.md`
section 2. Capture and resolve remaining deltas before extraction, including
INTENT drift around `EvidenceTarget` naming, the dropped
`derived-from` / `needs-check` relation values, and any event vocabulary that
is not yet canonicalized.
## Stand Up Standalone Package, Tests, And Repo Metadata - `services/`, `repos/`, `state/`, `visual-guide/`
- `BinderProvider.tsx`, `FormRenderer.tsx`, `FieldDefinitionForm.tsx`
- `../citation-evidence/wiki/SharedContracts.md` sections 2.4, 2.5, 4, and 7
- `../citation-evidence/wiki/DependencyMap.md` section 2
Resolve or explicitly record:
- INTENT drift around `EvidenceTarget` naming and target vocabulary
- dropped relation values `derived-from` / `needs-check`
- event vocabulary mismatches such as removal semantics versus the canonical bus
- exact files that extract into this repo versus remain umbrella-only
Acceptance:
- extraction inventory is explicit and bounded
- no unresolved contract contradiction remains hidden in `INTENT.md`
- forbidden dependency edges are called out before code copy starts
## T02 — Stand up standalone package, tests, and repo metadata
```task ```task
id: EBIND-WP-0001-T02 id: EBIND-WP-0001-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "122a254f-6490-4211-bde7-ce515c72c36a" state_hub_task_id: "122a254f-6490-4211-bde7-ce515c72c36a"
``` ```
Create the standalone package and test harness that this repo currently lacks, Create the package scaffold this repo currently lacks:
then refresh repo-local metadata so it stops looking like a placeholder. This
includes package entrypoints, test scripts, baseline CI/tooling, `README.md`,
`SCOPE.md`, and the stale capability metadata in
`registry/indexes/capabilities.yaml`.
## Extract Headless Binding Model And Repository - `package.json` with `test`, `lint`, and `typecheck` scripts
- TypeScript, Vitest, and lint config aligned with sibling extracted repos
- entrypoints for binder exports
- repo-local `README.md`, `SCOPE.md`, and capability metadata that describe the
package as real code rather than INTENT-only placeholder material
Acceptance:
- this repo can install, test, lint, and typecheck as an independent package
- top-level docs describe the extracted package surface and repo boundary
- `registry/indexes/capabilities.yaml` no longer advertises an empty placeholder
## T03 — Extract headless bindings, state, and provider composition
```task ```task
id: EBIND-WP-0001-T03 id: EBIND-WP-0001-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "d58501a4-b2e5-45fa-b504-e2c37ca48e68" state_hub_task_id: "d58501a4-b2e5-45fa-b504-e2c37ca48e68"
``` ```
Move the headless binding primitives from Extract the non-visual binder core:
`../citation-evidence/src/binder/repos/in-memory-links.ts` and
`../citation-evidence/src/binder/services/bindings.ts` with their tests. Keep
the current query directions, default relation/status behavior, and event-bus
emission semantics while staying independent of persistence, viewer internals,
`citation-work`, and `evidence-source`.
## Extract Active State And Provider Composition - `repos/in-memory-links.ts`
- `services/bindings.ts` and its tests
- `state/active.ts` and its tests
- `BinderProvider.tsx`
- package exports for the headless service and provider surface
Preserve:
- current query directions (`target -> links`, `evidence -> links`)
- default relation/status behavior
- active target / active evidence / active annotation coordination
- bus injection and `initialLinks` restore behavior
Acceptance:
- headless binder behavior runs in this repo with passing tests
- no persistence, viewer, `citation-work`, or `evidence-source` dependency is introduced
- the binder package can mount its provider and expose the current state API
## T04 — Extract rect registry, change pumps, and overlay
```task ```task
id: EBIND-WP-0001-T04 id: EBIND-WP-0001-T04
status: todo status: done
priority: high priority: high
depends_on: [T03]
state_hub_task_id: "b6f96550-c4a6-4ce1-bfde-4c91d33da687" state_hub_task_id: "b6f96550-c4a6-4ce1-bfde-4c91d33da687"
``` ```
Extract `state/active.ts` and `BinderProvider.tsx` so the binder owns the
active target / active evidence / active annotation state triple plus the
composition root that wires link storage, binding services, rect registry, and
engine bus access. Preserve `initialLinks` restore behavior and bus injection
without introducing a dependency on umbrella app code.
## Extract Rect Registry, Change Pumps, And Overlay
```task
id: EBIND-WP-0001-T05
status: todo
priority: high
state_hub_task_id: "58b55a71-eee4-4f3d-9bcd-7c3678320dd1"
```
Move the visual-guide contract from Move the visual-guide contract from
`../citation-evidence/src/binder/visual-guide/`, including `../citation-evidence/src/binder/visual-guide/`, including
`rect-registry.ts`, `events.ts`, `react-hooks.ts`, `Overlay.tsx`, and their `rect-registry.ts`, `events.ts`, `react-hooks.ts`, `Overlay.tsx`, and their
@ -102,27 +154,68 @@ tests. Preserve the section 7 rect kinds, invalidate/version semantics,
requestAnimationFrame throttling, and the contract that independent renderers requestAnimationFrame throttling, and the contract that independent renderers
publish rects into one registry. publish rects into one registry.
## Decide Binder-Owned Reference UI Surface Acceptance:
- section 7 rect-registry contract is implemented here without behavioral drift
- overlay redraw behavior still depends on rect invalidation rather than polling
- rect registry, hook, and overlay tests pass from this repo
## T05 — Decide binder-owned reference UI surface
```task
id: EBIND-WP-0001-T05
status: done
priority: medium
depends_on: [T03, T04]
state_hub_task_id: "58b55a71-eee4-4f3d-9bcd-7c3678320dd1"
```
Make an explicit decision for the small React form surface:
- keep `FormRenderer.tsx` and `FieldDefinitionForm.tsx` as supported binder exports, or
- move them to examples/reference UI and narrow the package surface accordingly
The decision must preserve the architectural rule that binder is form-friendly
but target-neutral and does not depend on `citation-work`.
Acceptance:
- the repo documents which React UI pieces are contractual versus illustrative
- any retained UI exports compile and test here
- any demoted UI files no longer blur the subsystem boundary
## T06 — Refresh docs and local contract references
```task ```task
id: EBIND-WP-0001-T06 id: EBIND-WP-0001-T06
status: todo status: done
priority: medium priority: medium
depends_on: [T01, T02]
state_hub_task_id: "39164219-5f35-49cf-874a-00fd0c902007" state_hub_task_id: "39164219-5f35-49cf-874a-00fd0c902007"
``` ```
Decide whether `FormRenderer.tsx` and `FieldDefinitionForm.tsx` remain binder Update the repo's local reference material so the extracted package is
exports, move into examples, or shift into an application layer. Whatever the understandable without reopening the umbrella implementation:
outcome, keep binder form-friendly but target-neutral, avoid a `citation-work`
dependency, and document which React surfaces are supported versus merely
illustrative.
## Cut Over Umbrella Integration And Verify Boundaries - refresh `README.md` and `SCOPE.md`
- update `INTENT.md` where it still implies placeholder-only state
- add or copy the minimum local contract references needed for conformance
- document the dependency rule: binder may depend on engine and anchor, not on
source, work, or umbrella behavior
Acceptance:
- a developer can orient from this repo alone and understand what extracts here
- local docs do not contradict the umbrella shared-contract authority
- repo metadata no longer looks like a pre-extraction stub
## T07 — Cut over umbrella integration and verify boundaries
```task ```task
id: EBIND-WP-0001-T07 id: EBIND-WP-0001-T07
status: todo status: done
priority: high priority: high
depends_on: [T04, T05, T06]
state_hub_task_id: "efceaee5-43a8-4ecf-9025-a5843eecada0" state_hub_task_id: "efceaee5-43a8-4ecf-9025-a5843eecada0"
``` ```
@ -133,17 +226,39 @@ edges to `citation-evidence` behavior, `citation-work`, or `evidence-source`,
and keep shared-contract changes flowing through the umbrella docs until the and keep shared-contract changes flowing through the umbrella docs until the
cutover is complete. cutover is complete.
## Exit Criteria Acceptance:
- umbrella resolves binder imports through this repo rather than a duplicate local copy
- binder-specific code is deleted or reduced to package-facing glue in the umbrella repo
- dependency-boundary checks still enforce the allowed edge set after wireup
## T08 — Verification and closeout
```task ```task
id: EBIND-WP-0001-T08 id: EBIND-WP-0001-T08
status: todo status: done
priority: medium priority: medium
depends_on: [T07]
state_hub_task_id: "2c89dfcb-1526-46d9-936d-8f97f4e48d76" state_hub_task_id: "2c89dfcb-1526-46d9-936d-8f97f4e48d76"
``` ```
Exit when this repo exports the binder service, active-state flow, and Verification:
visual-guide rect-registry contract with passing tests; repo docs and metadata
match the canonical shared contracts; and the umbrella app can define a target, - `pnpm test` is green in `evidence-binder`
link evidence, activate evidence, and draw the field-to-card-to-highlight - `pnpm typecheck` is green in `evidence-binder`
overlay without relying on the upstream `src/binder/` implementation. - `pnpm lint` is green in `evidence-binder`
- umbrella consumer tests/typecheck/lint stay green after wireup
- manual smoke still covers the field -> evidence card -> highlight visual guide path
Acceptance:
- this repo exports the binder service, active-state flow, and rect-registry contract
- umbrella behavior is unchanged on the MVP evidence-backed form flow
- all tasks can be closed individually by a Ralph loop without further task splitting
## Out Of Scope
- changing canonical enum vocabularies outside the shared-contract process
- taking ownership of document ingestion, selector resolution, or viewer internals
- making `citation-work` import binder directly
- publish/distribution work beyond the extraction and sibling-consumer wireup