diff --git a/src/anchor/index.ts b/src/anchor/index.ts
index 9da25b4..497fa98 100644
--- a/src/anchor/index.ts
+++ b/src/anchor/index.ts
@@ -1,6 +1,7 @@
export * from "./types";
export {
PdfSpikeViewer,
+ getHighlightClientRects,
selectorsFromPdfCapture,
type PdfSpikeViewerProps,
type StoredAnnotation,
diff --git a/src/anchor/pdf-viewer-adapter-spike.tsx b/src/anchor/pdf-viewer-adapter-spike.tsx
index d12530d..50751fc 100644
--- a/src/anchor/pdf-viewer-adapter-spike.tsx
+++ b/src/anchor/pdf-viewer-adapter-spike.tsx
@@ -113,13 +113,50 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
*/
function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
+ // Wrap the highlight in a data-tagged container so the visual-guide
+ // overlay's HighlightRectBridge can locate it via DOM query. The
+ // wrapper uses display: contents so it doesn't affect layout — the
+ // bounding rect is gathered from the live TextHighlight children at
+ // query time.
return (
-
-
-
+
+
+
+
+
);
}
+/**
+ * Resolve the rendered DOM rect for a highlight by data attribute, or
+ * `null` if the highlight isn't currently rendered (e.g. its page hasn't
+ * scrolled into view). Used by `app/forms/HighlightRectBridge` to feed
+ * the rect registry as kind="highlight".
+ *
+ * `display: contents` on the wrapper means it has no box of its own; we
+ * union the rects of its children. For TextHighlight that's typically
+ * one rect per line.
+ */
+export function getHighlightClientRects(annotationId: string): DOMRect | null {
+ if (typeof document === "undefined") return null;
+ const wrapper = document.querySelector(`[data-highlight-id="${CSS.escape(annotationId)}"]`);
+ if (!wrapper) return null;
+ const rects = wrapper.getClientRects();
+ if (rects.length === 0) return null;
+ let left = Infinity;
+ let top = Infinity;
+ let right = -Infinity;
+ let bottom = -Infinity;
+ for (const r of Array.from(rects)) {
+ left = Math.min(left, r.left);
+ top = Math.min(top, r.top);
+ right = Math.max(right, r.right);
+ bottom = Math.max(bottom, r.bottom);
+ }
+ if (!isFinite(left)) return null;
+ return new DOMRect(left, top, right - left, bottom - top);
+}
+
export interface PdfSpikeViewerProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 996a98c..b907398 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,40 +1,136 @@
/**
* App — the citation-evidence MVP shell.
*
- * Three-pane layout per `wiki/ArchitectureOverview.md` §12.1:
+ * Composes the two top-level layouts:
*
- * ┌────────────┬──────────────────┬────────────┐
- * │ Collection │ Document Viewer │ Evidence │
- * │ List │ │ Sidebar │
- * └────────────┴──────────────────┴────────────┘
+ * - Review mode (CE-WP-0002): collection list / viewer / evidence sidebar.
+ * - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip,
+ * with click-to-link interaction.
*
- * CE-WP-0002-T06 stops at "viewer shell is rendered, evidence list is
- * displayed". T07 wires the selection → annotation → evidence flow; T08
- * wires the sidebar-click → scroll-to-passage round-trip.
+ * Mode selection is driven by `location.hash`: `#/forms/demo` lands in
+ * Forms mode; anything else (including empty) lands in Review mode. The
+ * top bar toggles between them. We keep the hash sync so reload + deep
+ * links work; T08's E2E asserts the `/forms/demo` navigation path.
+ *
+ * Engine and binder providers are both mounted at the App root so
+ * evidence/annotations/links survive switching tabs.
*/
+import { useEffect, useState } from "react";
+
+import { BinderProvider } from "@binder/index";
import {
- CollectionList,
EngineProvider,
- EvidenceSidebar,
- ViewerShell,
+ useEngine,
} from "@work/index";
+import { FormsApp } from "./forms/FormsApp";
+import { ReviewLayout } from "./ReviewLayout";
+
+type Mode = "review" | "forms";
+
+const FORMS_HASH = "#/forms/demo";
+
+function readModeFromHash(): Mode {
+ if (typeof window === "undefined") return "review";
+ return window.location.hash === FORMS_HASH ? "forms" : "review";
+}
+
+function writeModeToHash(mode: Mode) {
+ if (typeof window === "undefined") return;
+ const target = mode === "forms" ? FORMS_HASH : "";
+ if (window.location.hash !== target) {
+ if (target) {
+ window.location.hash = target;
+ } else {
+ // Clear hash without leaving "#" trailing in the URL bar.
+ history.replaceState(null, "", window.location.pathname + window.location.search);
+ }
+ }
+}
+
+function ModeRouter() {
+ const [mode, setMode] = useState(() => readModeFromHash());
+
+ useEffect(() => {
+ function onHash() {
+ setMode(readModeFromHash());
+ }
+ window.addEventListener("hashchange", onHash);
+ return () => window.removeEventListener("hashchange", onHash);
+ }, []);
+
+ const handleModeChange = (next: Mode) => {
+ writeModeToHash(next);
+ setMode(next);
+ };
+
+ return (
+
+
+
+ {mode === "review" ? : }
+
+
+ );
+}
+
+function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
+ return (
+
+ citation-evidence
+ onModeChange("review")}
+ aria-pressed={mode === "review"}
+ style={tabStyle(mode === "review")}
+ >
+ Review
+
+ onModeChange("forms")}
+ aria-pressed={mode === "forms"}
+ style={tabStyle(mode === "forms")}
+ >
+ Forms
+
+
+ );
+}
+
+function tabStyle(active: boolean) {
+ return {
+ padding: "4px 12px",
+ fontSize: 12,
+ border: "1px solid #ccc",
+ borderBottom: active ? "2px solid #0050b3" : "1px solid #ccc",
+ background: active ? "#e8f0ff" : "white",
+ cursor: "pointer" as const,
+ };
+}
+
+function AppInner() {
+ const engine = useEngine();
+ return (
+
+
+
+ );
+}
+
export function App() {
return (
-
-
-
-
-
+
);
}
diff --git a/src/app/ReviewLayout.tsx b/src/app/ReviewLayout.tsx
new file mode 100644
index 0000000..92cc275
--- /dev/null
+++ b/src/app/ReviewLayout.tsx
@@ -0,0 +1,30 @@
+/**
+ * Review mode — the three-pane layout from CE-WP-0002-T06.
+ *
+ * ┌────────────┬──────────────────┬────────────┐
+ * │ Collection │ Document Viewer │ Evidence │
+ * │ List │ │ Sidebar │
+ * └────────────┴──────────────────┴────────────┘
+ */
+
+import {
+ CollectionList,
+ EvidenceSidebar,
+ ViewerShell,
+} from "@work/index";
+
+export function ReviewLayout() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/app/forms/ActiveEvidenceChips.tsx b/src/app/forms/ActiveEvidenceChips.tsx
new file mode 100644
index 0000000..df3dede
--- /dev/null
+++ b/src/app/forms/ActiveEvidenceChips.tsx
@@ -0,0 +1,131 @@
+/**
+ * ActiveEvidenceChips — chip strip for the currently-focused field.
+ *
+ * Renders one chip per link on the active target. The chip:
+ * - is a focusable `` so Tab/Shift-Tab cycles natively;
+ * - registers itself with the rect registry as `kind="evidence-card"`
+ * and `id=evidenceItemId` (T07's overlay will draw from these);
+ * - calls `setActiveEvidence(evidenceItemId, annotationId)` on focus
+ * so the active-state machine + viewer scroll stay in sync.
+ *
+ * Auto-activation: when the active target changes and it has links, we
+ * focus the first chip. That gives the user immediate evidence preview
+ * without an extra click.
+ */
+
+import { useEffect, useRef } from "react";
+
+import type { EvidenceItemId } from "@shared/ids";
+
+import {
+ useActiveState,
+ useRegisterRect,
+} from "@binder/index";
+
+export interface ActiveEvidenceChipsItem {
+ readonly evidenceItemId: EvidenceItemId;
+ readonly annotationId: import("@shared/ids").AnnotationId | null;
+ readonly quote: string;
+ readonly commentary?: string;
+}
+
+export interface ActiveEvidenceChipsProps {
+ readonly items: readonly ActiveEvidenceChipsItem[];
+}
+
+function Chip({
+ item,
+ isActive,
+}: {
+ item: ActiveEvidenceChipsItem;
+ isActive: boolean;
+}) {
+ const ref = useRef(null);
+ useRegisterRect("evidence-card", item.evidenceItemId, ref);
+ const { setActiveEvidence } = useActiveState();
+
+ return (
+ setActiveEvidence(item.evidenceItemId, item.annotationId)}
+ onClick={() => setActiveEvidence(item.evidenceItemId, item.annotationId)}
+ aria-current={isActive ? "true" : undefined}
+ data-active={isActive ? "true" : "false"}
+ data-evidence-id={item.evidenceItemId}
+ style={{
+ minWidth: 200,
+ maxWidth: 260,
+ textAlign: "left",
+ fontSize: 12,
+ padding: 6,
+ border: isActive ? "2px solid #0050b3" : "1px solid #aac",
+ background: isActive ? "#e8f0ff" : "#fffceb",
+ cursor: "pointer",
+ }}
+ >
+
+ “{item.quote.slice(0, 80)}
+ {item.quote.length > 80 ? "…" : ""}”
+
+ {item.commentary && {item.commentary}
}
+
+ );
+}
+
+export function ActiveEvidenceChips({ items }: ActiveEvidenceChipsProps) {
+ const { state, setActiveEvidence } = useActiveState();
+ const targetKey = state.activeTarget
+ ? `${state.activeTarget.targetType}:${state.activeTarget.targetId}`
+ : null;
+
+ // Auto-activate the first item whenever the active target changes and
+ // we have something to show.
+ useEffect(() => {
+ if (!targetKey) return;
+ if (items.length === 0) return;
+ if (state.activeEvidenceItemId) return; // already active
+ const first = items[0]!;
+ setActiveEvidence(first.evidenceItemId, first.annotationId);
+ }, [targetKey, items, state.activeEvidenceItemId, setActiveEvidence]);
+
+ if (!state.activeTarget) return null;
+ if (items.length === 0) {
+ return (
+
+ No evidence linked to this field yet.
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx
new file mode 100644
index 0000000..fa3702b
--- /dev/null
+++ b/src/app/forms/FormsApp.tsx
@@ -0,0 +1,322 @@
+/**
+ * FormsApp — the evidence-backed form mode for CE-WP-0003.
+ *
+ * Layout:
+ *
+ * ┌────────────┬─────────────────┬─────────────┐
+ * │ Collection │ FormRenderer │ ViewerShell │
+ * │ │ (left) │ (right) │
+ * ├────────────┴─────────────────┴─────────────┤
+ * │ EvidenceStrip (bottom) │
+ * └────────────────────────────────────────────┘
+ *
+ * Linking interaction (T05):
+ * 1. User clicks an evidence card in the strip → it becomes "selected
+ * for linking" (highlighted; banner appears in the form pane).
+ * 2. User then clicks a form field. The field's `FormFieldActivated`
+ * event triggers `bindings.linkEvidenceToTarget(selected, field)`.
+ * 3. The selected-for-linking state clears; the field's link count
+ * chip increments.
+ *
+ * Active-evidence cycling (T06) and the visual guide overlay (T07) build
+ * on this composition without changing the link interaction.
+ */
+
+import { useEffect, useMemo, useState } from "react";
+
+import type { EvidenceItem } from "@shared/evidence";
+import type { AnnotationId, EvidenceItemId } from "@shared/ids";
+
+import { Overlay, useActiveState, useBinder } from "@binder/index";
+import {
+ CollectionList,
+ ViewerShell,
+ useActiveDocument,
+ useEngine,
+ useEngineEventTick,
+ useScrollToAnnotation,
+} from "@work/index";
+
+import { FormRenderer } from "@binder/FormRenderer";
+
+import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips";
+import { DEMO_SCHEMA } from "./demo-schema";
+import { HighlightRectBridge } from "./HighlightRectBridge";
+
+export function FormsApp() {
+ return (
+
+ );
+}
+
+/**
+ * Bridge: the binder's active-state machine doesn't know how to scroll
+ * the viewer; the work-level `useScrollToAnnotation` does. This subscriber
+ * reads `activeAnnotationId` from the binder and calls `scrollTo` whenever
+ * it changes. Lives in app/ because that's where both subsystems meet.
+ */
+function ScrollBridge() {
+ const { state } = useActiveState();
+ const { scrollTo } = useScrollToAnnotation();
+ useEffect(() => {
+ if (state.activeAnnotationId) {
+ scrollTo(state.activeAnnotationId);
+ }
+ }, [state.activeAnnotationId, scrollTo]);
+ return null;
+}
+
+function FormPane() {
+ const { document } = useActiveDocument();
+ const { bindings } = useBinder();
+ const engine = useEngine();
+ const linkTick = useEngineEventTick("EvidenceLinkCreated");
+ const { state: activeState } = useActiveState();
+
+ const [selectedForLinking, setSelectedForLinking] = useState(
+ null,
+ );
+
+ // Compute per-field link counts. Re-derives on link create.
+ const linkCounts = useMemo>(() => {
+ const out: Record = {};
+ for (const field of DEMO_SCHEMA.fields) {
+ out[field.id] = bindings.listEvidenceForTarget({
+ targetType: "form-field",
+ targetId: field.id,
+ }).length;
+ }
+ void linkTick;
+ return out;
+ }, [bindings, linkTick]);
+
+ // Compute chip items for the currently-active target.
+ const activeChipItems = useMemo(() => {
+ if (!activeState.activeTarget) return [];
+ const links = bindings.listEvidenceForTarget(activeState.activeTarget);
+ return links
+ .map((link): ActiveEvidenceChipsItem | null => {
+ const item = engine.evidence.get(link.evidenceItemId);
+ if (!item) return null;
+ const annotationId: AnnotationId | null = item.annotationIds[0] ?? null;
+ const annotation = annotationId ? engine.annotations.get(annotationId) : null;
+ return {
+ evidenceItemId: link.evidenceItemId,
+ annotationId,
+ quote: annotation?.quote ?? "(no quote)",
+ ...(item.commentary ? { commentary: item.commentary } : {}),
+ };
+ })
+ .filter((c): c is ActiveEvidenceChipsItem => c !== null);
+ // linkTick is included so newly created links populate the chips
+ // without an explicit refresh.
+ }, [activeState.activeTarget, bindings, engine, linkTick]);
+
+ // Listen for FormFieldActivated and, if an evidence is staged, create
+ // the link. The state machine reduction (focus-target) happens in
+ // parallel via ActiveStateProvider's own handler.
+ useEffect(() => {
+ return engine.bus.on("FormFieldActivated", (event) => {
+ if (!selectedForLinking) return;
+ bindings.linkEvidenceToTarget({
+ evidenceItemId: selectedForLinking,
+ target: event.target,
+ });
+ setSelectedForLinking(null);
+ });
+ }, [engine, bindings, selectedForLinking]);
+
+ return (
+
+ setSelectedForLinking(null)}
+ />
+ {document ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function EmptyHint() {
+ return (
+
+ Pick a fixture from the collection list to start binding evidence.
+
+ );
+}
+
+function SelectedBanner({
+ selectedForLinking,
+ onClear,
+}: {
+ selectedForLinking: EvidenceItemId | null;
+ onClear: () => void;
+}) {
+ if (!selectedForLinking) return null;
+ return (
+
+
+ Evidence staged for linking. Click a form field to link it, or{" "}
+
+
+ cancel
+
+
+ );
+}
+
+/**
+ * Bridges the strip's "stage this evidence" callback to the FormPane's
+ * local state. The strip lives in a sibling DOM subtree; rather than
+ * lifting `selectedForLinking` all the way up to FormsApp, we publish a
+ * setter into a module-scoped event target.
+ *
+ * Simpler than another context for one local handshake.
+ */
+const STAGED_EVENT = "citation-evidence:staged-for-linking";
+
+function SelectionContext({
+ setSelected,
+}: {
+ setSelected: (id: EvidenceItemId | null) => void;
+}) {
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ setSelected(detail);
+ };
+ window.addEventListener(STAGED_EVENT, handler);
+ return () => window.removeEventListener(STAGED_EVENT, handler);
+ }, [setSelected]);
+ return null;
+}
+
+export function publishStagedForLinking(id: EvidenceItemId | null) {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id }));
+}
+
+function EvidenceStrip() {
+ const engine = useEngine();
+ const { document } = useActiveDocument();
+ const createTick = useEngineEventTick("EvidenceItemCreated");
+ const updateTick = useEngineEventTick("EvidenceItemUpdated");
+ const linkTick = useEngineEventTick("EvidenceLinkCreated");
+ const { state: activeState } = useActiveState();
+ const [stagedId, setStagedId] = useState(null);
+
+ const items = useMemo(() => {
+ if (!document) return [];
+ void createTick;
+ void updateTick;
+ void linkTick;
+ return engine.evidence.listByDocument(document.id);
+ }, [document, engine, createTick, updateTick, linkTick]);
+
+ const handleStage = (id: EvidenceItemId) => {
+ const next = stagedId === id ? null : id;
+ setStagedId(next);
+ publishStagedForLinking(next);
+ };
+
+ if (!document) return null;
+
+ return (
+
+ {items.length === 0 && (
+
+ No evidence yet. Switch to Review mode to capture a passage.
+
+ )}
+ {items.map((item) => {
+ const firstAnn = item.annotationIds[0]
+ ? engine.annotations.get(item.annotationIds[0])
+ : null;
+ const quote = firstAnn?.quote ?? "(no quote)";
+ const isStaged = stagedId === item.id;
+ const isActive = activeState.activeEvidenceItemId === item.id;
+ return (
+ handleStage(item.id)}
+ data-staged={isStaged ? "true" : "false"}
+ aria-current={isActive ? "true" : undefined}
+ style={{
+ minWidth: 220,
+ maxWidth: 280,
+ textAlign: "left",
+ fontSize: 12,
+ padding: 8,
+ border: isActive
+ ? "2px solid #0050b3"
+ : isStaged
+ ? "2px solid #f0a000"
+ : "1px solid #ccc",
+ background: isActive ? "#e8f0ff" : isStaged ? "#fff4d6" : "white",
+ cursor: "pointer",
+ }}
+ >
+
+ “{quote.slice(0, 100)}
+ {quote.length > 100 ? "…" : ""}”
+
+ {item.commentary && (
+ {item.commentary}
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/forms/HighlightRectBridge.tsx b/src/app/forms/HighlightRectBridge.tsx
new file mode 100644
index 0000000..5c9c061
--- /dev/null
+++ b/src/app/forms/HighlightRectBridge.tsx
@@ -0,0 +1,46 @@
+/**
+ * HighlightRectBridge — wires the viewer's rendered highlight DOM into
+ * the binder's rect registry as `kind="highlight"`.
+ *
+ * The viewer adapter exposes `getHighlightClientRects(annotationId)`
+ * (CE-WP-0003-T07) which returns the live bounding rect of a highlight
+ * by data attribute. We register a lazy callback that re-runs that
+ * lookup on every `rect-changed` event from the scroll/resize pump, so
+ * even as the user scrolls, the registered rect tracks the visible
+ * position.
+ *
+ * Lives in app/ because it spans:
+ * - binder (rect registry)
+ * - work (active document, scroll bridge)
+ * - anchor (the DOM-query helper)
+ *
+ * If the active annotation isn't currently rendered (its page is off
+ * screen, or no highlight matched), the callback returns null and the
+ * overlay omits the card→highlight curve until it becomes visible.
+ */
+
+import { useEffect } from "react";
+
+import { getHighlightClientRects } from "@anchor/index";
+import {
+ useActiveState,
+ useRectRegistryContext,
+} from "@binder/index";
+
+export function HighlightRectBridge() {
+ const { state } = useActiveState();
+ const { registry } = useRectRegistryContext();
+
+ useEffect(() => {
+ const annotationId = state.activeAnnotationId;
+ if (!annotationId) return;
+ const unregister = registry.register(
+ "highlight",
+ annotationId,
+ () => getHighlightClientRects(annotationId),
+ );
+ return unregister;
+ }, [state.activeAnnotationId, registry]);
+
+ return null;
+}
diff --git a/src/app/forms/demo-schema.ts b/src/app/forms/demo-schema.ts
new file mode 100644
index 0000000..386f2f2
--- /dev/null
+++ b/src/app/forms/demo-schema.ts
@@ -0,0 +1,29 @@
+/**
+ * Demo form schema for CE-WP-0003 (the form-binding slice).
+ *
+ * Deliberately minimal: text, textarea, date. JSON Schema is **not** used
+ * here — that's deferred to a later ADR. The MVP form's only job is to
+ * render a handful of fields and accept evidence links so the visual-guide
+ * round-trip can be exercised end-to-end.
+ */
+
+export type FormFieldSchema =
+ | { readonly type: "text"; readonly id: string; readonly label: string }
+ | { readonly type: "textarea"; readonly id: string; readonly label: string }
+ | { readonly type: "date"; readonly id: string; readonly label: string };
+
+export interface FormSchema {
+ readonly id: string;
+ readonly title: string;
+ readonly fields: readonly FormFieldSchema[];
+}
+
+export const DEMO_SCHEMA: FormSchema = {
+ id: "demo-form",
+ title: "Demo evidence-backed form",
+ fields: [
+ { type: "textarea", id: "summary", label: "Summary of the matter" },
+ { type: "date", id: "deadline", label: "Key deadline" },
+ { type: "text", id: "amount", label: "Disputed amount" },
+ ],
+};
diff --git a/src/binder/BinderProvider.tsx b/src/binder/BinderProvider.tsx
new file mode 100644
index 0000000..633cfb7
--- /dev/null
+++ b/src/binder/BinderProvider.tsx
@@ -0,0 +1,97 @@
+/**
+ * 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 { EventBus } from "@engine/events";
+
+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(null);
+
+export function useBinder(): BinderServices {
+ const ctx = useContext(BinderServicesContext);
+ if (!ctx) throw new Error("useBinder: missing ");
+ 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;
+}
+
+export function BinderProvider({ children, bus, services }: BinderProviderProps) {
+ const built = useMemo(() => {
+ const links = services?.links ?? createInMemoryLinkRepo();
+ const bindings = services?.bindings ?? createBindingService(links, bus);
+ const rect = createRectRegistryContextValue();
+ return { links, bindings, rect };
+ }, [bus, services]);
+
+ // Disconnect rect observers + listeners on unmount.
+ useEffect(() => {
+ return () => {
+ built.rect.observer.disconnect();
+ };
+ }, [built.rect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export { useActiveState };
diff --git a/src/binder/FormRenderer.dom.test.tsx b/src/binder/FormRenderer.dom.test.tsx
new file mode 100644
index 0000000..a4da6fe
--- /dev/null
+++ b/src/binder/FormRenderer.dom.test.tsx
@@ -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 "@engine/events";
+
+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[0]) {
+ const bus = createEventBus();
+ const events: EngineEvent[] = [];
+ bus.onAny((e) => events.push(e));
+ const ctxValue = createRectRegistryContextValue();
+ const utils = render(
+
+
+
+
+ ,
+ );
+ 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();
+ });
+});
diff --git a/src/binder/FormRenderer.tsx b/src/binder/FormRenderer.tsx
new file mode 100644
index 0000000..2d9364d
--- /dev/null
+++ b/src/binder/FormRenderer.tsx
@@ -0,0 +1,161 @@
+/**
+ * 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.
+ *
+ * Lives in `src/binder/` (not `src/work/`) because it depends on the
+ * rect-registry hooks in `binder/visual-guide`. See `wiki/DependencyMap.md`
+ * §2/§5 for the `work ⊄ binder` rule that motivates this placement.
+ *
+ * No styling beyond minimum legibility (workplan T06 note). Tailwind /
+ * design system can land later without changing the registry contract.
+ */
+
+import { useRef, type ChangeEvent } from "react";
+
+import type { EvidenceTarget } from "@shared/evidence-link";
+
+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 FormRendererProps {
+ readonly schema: FormSchema;
+ readonly values?: Readonly>;
+ readonly onValueChange?: (fieldId: string, value: string) => void;
+ /**
+ * Per-field annotation count. Rendered as a small chip beside the
+ * label so the user can tell which fields already have evidence.
+ */
+ readonly linkCounts?: Readonly>;
+}
+
+function FieldRow({
+ field,
+ value,
+ linkCount,
+ isActive,
+ onChange,
+ onFocus,
+}: {
+ field: FormFieldSchema;
+ value: string;
+ linkCount: number;
+ isActive: boolean;
+ onChange: (next: string) => void;
+ onFocus: () => void;
+}) {
+ const ref = useRef(null);
+ useRegisterRect("field", field.id, ref);
+
+ const sharedProps = {
+ id: `field-${field.id}`,
+ value,
+ onFocus,
+ onChange: (e: ChangeEvent) =>
+ onChange(e.target.value),
+ style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 },
+ };
+
+ return (
+
+
+ {field.label}
+ {linkCount > 0 ? (
+
+ {linkCount} evidence
+
+ ) : null}
+
+ {field.type === "textarea" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function FormRenderer({
+ schema,
+ values,
+ onValueChange,
+ linkCounts,
+}: FormRendererProps) {
+ const { state, focusTarget } = useActiveState();
+
+ const handleFocus = (fieldId: string) => {
+ const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
+ focusTarget(target);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/binder/index.ts b/src/binder/index.ts
index cb0ff5c..a2e88f4 100644
--- a/src/binder/index.ts
+++ b/src/binder/index.ts
@@ -1 +1,12 @@
-export {};
+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";
diff --git a/src/binder/repos/in-memory-links.ts b/src/binder/repos/in-memory-links.ts
new file mode 100644
index 0000000..cd4b553
Binary files /dev/null and b/src/binder/repos/in-memory-links.ts differ
diff --git a/src/binder/repos/index.ts b/src/binder/repos/index.ts
new file mode 100644
index 0000000..3a8f4cc
--- /dev/null
+++ b/src/binder/repos/index.ts
@@ -0,0 +1 @@
+export * from "./in-memory-links";
diff --git a/src/binder/services/bindings.test.ts b/src/binder/services/bindings.test.ts
new file mode 100644
index 0000000..53599e8
--- /dev/null
+++ b/src/binder/services/bindings.test.ts
@@ -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 "@shared/evidence-link";
+import type {
+ EvidenceItemId,
+ EvidenceLinkId,
+} from "@shared/ids";
+
+import { createEventBus } from "@engine/events";
+import type { EngineEvent } from "@engine/events";
+
+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).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/);
+ });
+});
diff --git a/src/binder/services/bindings.ts b/src/binder/services/bindings.ts
new file mode 100644
index 0000000..a1b904b
--- /dev/null
+++ b/src/binder/services/bindings.ts
@@ -0,0 +1,114 @@
+/**
+ * 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 "@shared/evidence-link";
+import type { EvidenceItemId, EvidenceLinkId } from "@shared/ids";
+import { newId } from "@shared/ids";
+
+import type { EventBus } from "@engine/events";
+
+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) {
+ return links.delete(id);
+ },
+ 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",
+ });
+ },
+ };
+}
diff --git a/src/binder/services/index.ts b/src/binder/services/index.ts
new file mode 100644
index 0000000..9e592d7
--- /dev/null
+++ b/src/binder/services/index.ts
@@ -0,0 +1 @@
+export * from "./bindings";
diff --git a/src/binder/state/active.test.ts b/src/binder/state/active.test.ts
new file mode 100644
index 0000000..8244394
--- /dev/null
+++ b/src/binder/state/active.test.ts
@@ -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 "@shared/evidence-link";
+import type { AnnotationId, EvidenceItemId } from "@shared/ids";
+
+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);
+ });
+});
diff --git a/src/binder/state/active.ts b/src/binder/state/active.ts
new file mode 100644
index 0000000..13fe068
--- /dev/null
+++ b/src/binder/state/active.ts
@@ -0,0 +1,171 @@
+/**
+ * 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 "@shared/evidence-link";
+import type { AnnotationId, EvidenceItemId } from "@shared/ids";
+
+import type { EventBus } from "@engine/events";
+
+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" };
+
+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":
+ return EMPTY_ACTIVE_STATE;
+ }
+}
+
+export interface ActiveStateApi {
+ readonly state: ActiveState;
+ focusTarget(target: EvidenceTarget): void;
+ setActiveEvidence(
+ evidenceItemId: EvidenceItemId,
+ annotationId?: AnnotationId | null,
+ ): void;
+ clear(): void;
+}
+
+const ActiveStateContext = createContext(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 clear = useCallback(() => {
+ dispatch({ type: "clear" });
+ }, []);
+
+ const value = useMemo(
+ () => ({ state, focusTarget, setActiveEvidence, clear }),
+ [state, focusTarget, setActiveEvidence, 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 ");
+ }
+ 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 };
diff --git a/src/binder/state/index.ts b/src/binder/state/index.ts
new file mode 100644
index 0000000..7c369e0
--- /dev/null
+++ b/src/binder/state/index.ts
@@ -0,0 +1 @@
+export * from "./active";
diff --git a/src/binder/visual-guide/Overlay.dom.test.tsx b/src/binder/visual-guide/Overlay.dom.test.tsx
new file mode 100644
index 0000000..6c572df
--- /dev/null
+++ b/src/binder/visual-guide/Overlay.dom.test.tsx
@@ -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 "@engine/events";
+
+import { Overlay } from "./Overlay";
+import { ActiveStateProvider, useActiveState } from "../state/active";
+import {
+ RectRegistryProvider,
+ createRectRegistryContextValue,
+ type RectRegistryContextValue,
+} from "./react-hooks";
+import type { EvidenceTarget } from "@shared/evidence-link";
+import type { AnnotationId, EvidenceItemId } from "@shared/ids";
+
+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) => 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(
+
+
+
+
+ ,
+ );
+ expect(container.querySelector("svg")).toBeNull();
+ });
+
+ it("draws one path when only field + card rects are registered", async () => {
+ const bus = createEventBus();
+ let api: ReturnType | null = null;
+ render(
+
+
+ (api = a)} />
+
+
+ ,
+ );
+
+ // 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 | null = null;
+ render(
+
+
+ (api = a)} />
+
+
+ ,
+ );
+
+ 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 | null = null;
+ render(
+
+
+ (api = a)} />
+
+
+ ,
+ );
+ 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);
+ });
+});
diff --git a/src/binder/visual-guide/Overlay.tsx b/src/binder/visual-guide/Overlay.tsx
new file mode 100644
index 0000000..8a5f19b
--- /dev/null
+++ b/src/binder/visual-guide/Overlay.tsx
@@ -0,0 +1,115 @@
+/**
+ * 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 };
+}
+
+/**
+ * Build a quadratic bezier from `a` to `b` whose control point bulges
+ * horizontally between them. The horizontal-bulge style is right for a
+ * left-pane→centre-pane→right-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 = "#0050b3",
+ strokeWidth = 2,
+ className,
+}: OverlayProps = {}) {
+ const { state } = useActiveState();
+ const { registry } = useRectRegistryContext();
+ const version = useRectRegistryVersion();
+
+ const paths = useMemo(() => {
+ 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(rectCenter(fieldRect), rectCenter(cardRect)));
+ }
+ if (cardRect && highlightRect) {
+ out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
+ }
+ void version; // memo invalidator
+ return out;
+ }, [state, registry, version]);
+
+ if (paths.length === 0) return null;
+
+ return (
+
+ {paths.map((d, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/binder/visual-guide/events.ts b/src/binder/visual-guide/events.ts
new file mode 100644
index 0000000..8ab91ad
--- /dev/null
+++ b/src/binder/visual-guide/events.ts
@@ -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();
+
+ 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();
+ },
+ };
+}
diff --git a/src/binder/visual-guide/index.ts b/src/binder/visual-guide/index.ts
new file mode 100644
index 0000000..0d487f7
--- /dev/null
+++ b/src/binder/visual-guide/index.ts
@@ -0,0 +1,4 @@
+export * from "./rect-registry";
+export * from "./events";
+export * from "./react-hooks";
+export { Overlay, type OverlayProps } from "./Overlay";
diff --git a/src/binder/visual-guide/react-hooks.dom.test.tsx b/src/binder/visual-guide/react-hooks.dom.test.tsx
new file mode 100644
index 0000000..8641309
--- /dev/null
+++ b/src/binder/visual-guide/react-hooks.dom.test.tsx
@@ -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(null);
+ useRegisterRect("field", id, ref);
+ const version = useRectRegistryVersion();
+ onVersion?.(version);
+ return
;
+}
+
+function CtxSpy({ onCtx }: { onCtx: (registry: ReturnType) => void }) {
+ const ctx = useRectRegistryContext();
+ onCtx(ctx);
+ return null;
+}
+
+describe("useRegisterRect (happy-dom)", () => {
+ let ctxValue: ReturnType;
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ // 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(
+
+ seen.push(v)}
+ />
+ ,
+ );
+
+ // 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 | undefined;
+ let secondCtx: ReturnType | undefined;
+ render(
+
+ (firstCtx = c)} />
+ (secondCtx = c)} />
+ ,
+ );
+ expect(firstCtx).toBe(secondCtx);
+ expect(firstCtx?.registry).toBe(ctxValue.registry);
+ });
+});
diff --git a/src/binder/visual-guide/react-hooks.ts b/src/binder/visual-guide/react-hooks.ts
new file mode 100644
index 0000000..04ddb65
--- /dev/null
+++ b/src/binder/visual-guide/react-hooks.ts
@@ -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(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 ",
+ );
+ }
+ 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,
+): 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);
+}
diff --git a/src/binder/visual-guide/rect-registry.test.ts b/src/binder/visual-guide/rect-registry.test.ts
new file mode 100644
index 0000000..8ce1984
--- /dev/null
+++ b/src/binder/visual-guide/rect-registry.test.ts
@@ -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" },
+ ]),
+ );
+ });
+});
diff --git a/src/binder/visual-guide/rect-registry.ts b/src/binder/visual-guide/rect-registry.ts
new file mode 100644
index 0000000..cbce679
Binary files /dev/null and b/src/binder/visual-guide/rect-registry.ts differ
diff --git a/src/engine/events/types.ts b/src/engine/events/types.ts
index 2bff9c6..eb2ec21 100644
--- a/src/engine/events/types.ts
+++ b/src/engine/events/types.ts
@@ -11,10 +11,15 @@
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
+import type {
+ EvidenceLink,
+ EvidenceTarget,
+} from "@shared/evidence-link";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
+ EvidenceLinkId,
RepresentationId,
} from "@shared/ids";
@@ -69,6 +74,24 @@ export interface EvidenceItemActivatedEvent {
readonly source?: "sidebar" | "form-field" | "citation-card";
}
+export interface EvidenceLinkCreatedEvent {
+ readonly type: "EvidenceLinkCreated";
+ readonly linkId: EvidenceLinkId;
+ readonly link: EvidenceLink;
+}
+
+export interface EvidenceLinkUpdatedEvent {
+ readonly type: "EvidenceLinkUpdated";
+ readonly linkId: EvidenceLinkId;
+ readonly link: EvidenceLink;
+}
+
+export interface FormFieldActivatedEvent {
+ readonly type: "FormFieldActivated";
+ readonly target: EvidenceTarget;
+ readonly previousTarget?: EvidenceTarget;
+}
+
export type EngineEvent =
| DocumentImportedEvent
| DocumentRepresentationGeneratedEvent
@@ -77,7 +100,10 @@ export type EngineEvent =
| AnnotationResolutionFailedEvent
| EvidenceItemCreatedEvent
| EvidenceItemUpdatedEvent
- | EvidenceItemActivatedEvent;
+ | EvidenceItemActivatedEvent
+ | EvidenceLinkCreatedEvent
+ | EvidenceLinkUpdatedEvent
+ | FormFieldActivatedEvent;
export type EngineEventType = EngineEvent["type"];
diff --git a/src/shared/evidence-link.test.ts b/src/shared/evidence-link.test.ts
new file mode 100644
index 0000000..3eb666f
--- /dev/null
+++ b/src/shared/evidence-link.test.ts
@@ -0,0 +1,62 @@
+/**
+ * Conformance test: the runtime enum lists in `evidence-link.ts` must
+ * match the lists in `wiki/SharedContracts.md` §2.4 and §2.5 exactly.
+ *
+ * If you intentionally change an enum, update both the doc and the
+ * runtime list together — this test will tell you which one you forgot.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ EVIDENCE_LINK_STATUS_VALUES,
+ EVIDENCE_RELATION_VALUES,
+} from "./evidence-link";
+
+const HERE = fileURLToPath(new URL(".", import.meta.url));
+const CONTRACTS_PATH = resolve(HERE, "../../wiki/SharedContracts.md");
+
+function extractFencedListAfterHeading(markdown: string, heading: string): string[] {
+ const headingIndex = markdown.indexOf(heading);
+ if (headingIndex === -1) {
+ throw new Error(`Could not find heading "${heading}" in SharedContracts.md`);
+ }
+ const after = markdown.slice(headingIndex + heading.length);
+ const fenceOpen = after.indexOf("```");
+ if (fenceOpen === -1) throw new Error(`No fenced block after "${heading}"`);
+ const bodyStart = after.indexOf("\n", fenceOpen) + 1;
+ const fenceClose = after.indexOf("```", bodyStart);
+ if (fenceClose === -1) throw new Error(`Unterminated fenced block after "${heading}"`);
+ const body = after.slice(bodyStart, fenceClose);
+ return body
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ // Strip trailing " — explanatory note" if present (none in §2.4/§2.5 today,
+ // but §2.1/§2.2 use that style — being defensive keeps the helper reusable).
+ .map((line) => line.split(/\s+[—-]\s+/)[0]!.trim());
+}
+
+describe("EvidenceLink enum conformance with SharedContracts.md", () => {
+ const markdown = readFileSync(CONTRACTS_PATH, "utf8");
+
+ it("§2.4 EvidenceLink.status matches EVIDENCE_LINK_STATUS_VALUES", () => {
+ const docValues = extractFencedListAfterHeading(
+ markdown,
+ "### 2.4 `EvidenceLink.status` (per target)",
+ );
+ expect(docValues).toEqual([...EVIDENCE_LINK_STATUS_VALUES]);
+ });
+
+ it("§2.5 EvidenceLink.relation matches EVIDENCE_RELATION_VALUES", () => {
+ const docValues = extractFencedListAfterHeading(
+ markdown,
+ "### 2.5 `EvidenceLink.relation`",
+ );
+ expect(docValues).toEqual([...EVIDENCE_RELATION_VALUES]);
+ });
+});
diff --git a/src/shared/evidence-link.ts b/src/shared/evidence-link.ts
new file mode 100644
index 0000000..b754ee2
--- /dev/null
+++ b/src/shared/evidence-link.ts
@@ -0,0 +1,107 @@
+/**
+ * EvidenceLink + EvidenceTarget shapes.
+ *
+ * Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.4
+ * (EvidenceLink.status) and §2.5 (EvidenceLink.relation), and
+ * `wiki/ArchitectureOverview.md` §4 (target-type catalogue).
+ *
+ * An EvidenceLink ties exactly one EvidenceItem to one structured target
+ * (e.g. a form field). Multiple links per item are allowed when the same
+ * evidence supports several targets. Multiple links per target are allowed
+ * when several pieces of evidence apply to the same field — the
+ * EvidenceSet captures that ordered group.
+ */
+
+import type { EvidenceItemId, EvidenceLinkId } from "./ids";
+
+/**
+ * Closed enum per `wiki/SharedContracts.md` §2.4.
+ *
+ * `no-evidence` is a *derived* state — computed when a target has zero
+ * links — and is therefore NOT stored on a link itself. The stored values
+ * are the five members of `EvidenceLinkStoredStatus`.
+ */
+export type EvidenceLinkStatus =
+ | "no-evidence"
+ | "candidate"
+ | "confirmed"
+ | "conflicting"
+ | "insufficient"
+ | "verified";
+
+/**
+ * The subset of `EvidenceLinkStatus` that may appear on a stored link
+ * record. `no-evidence` is excluded because it is derived from the
+ * absence of links on a target, not stored.
+ */
+export type EvidenceLinkStoredStatus = Exclude;
+
+/** Closed enum per `wiki/SharedContracts.md` §2.5. */
+export type EvidenceRelation =
+ | "supports"
+ | "contradicts"
+ | "explains"
+ | "qualifies"
+ | "source-for"
+ | "context-for";
+
+/**
+ * Known target-type catalogue per `wiki/ArchitectureOverview.md` §4
+ * (`EvidenceTargetType`). The MVP only exercises `"form-field"`; the
+ * others are reserved so future workplans can extend without renaming.
+ */
+export type EvidenceTargetType =
+ | "form-field"
+ | "claim"
+ | "requirement"
+ | "decision"
+ | "document-section";
+
+/**
+ * Generic shape of an evidence target. `targetId` is opaque to the engine
+ * — the host subsystem (form renderer, claims index, …) owns the
+ * namespace for its `targetType`.
+ */
+export interface EvidenceTarget {
+ readonly targetType: EvidenceTargetType;
+ readonly targetId: string;
+}
+
+export interface EvidenceLink {
+ readonly id: EvidenceLinkId;
+ readonly evidenceItemId: EvidenceItemId;
+ readonly targetType: EvidenceTargetType;
+ readonly targetId: string;
+ readonly relation: EvidenceRelation;
+ readonly status: EvidenceLinkStoredStatus;
+ /** Optional 0..1 confidence assigned by user or auto-process. */
+ readonly confidence?: number;
+ readonly createdBy?: string;
+ /** ISO-8601 timestamp. */
+ readonly createdAt: string;
+ /** ISO-8601 timestamp. */
+ readonly updatedAt: string;
+}
+
+/**
+ * The canonical lists, exported for use by enum-conformance tests
+ * (see `evidence-link.test.ts`) and for any UI code that needs to
+ * enumerate options. Order matches `wiki/SharedContracts.md`.
+ */
+export const EVIDENCE_LINK_STATUS_VALUES: readonly EvidenceLinkStatus[] = [
+ "no-evidence",
+ "candidate",
+ "confirmed",
+ "conflicting",
+ "insufficient",
+ "verified",
+];
+
+export const EVIDENCE_RELATION_VALUES: readonly EvidenceRelation[] = [
+ "supports",
+ "contradicts",
+ "explains",
+ "qualifies",
+ "source-for",
+ "context-for",
+];
diff --git a/src/shared/evidence-set.ts b/src/shared/evidence-set.ts
new file mode 100644
index 0000000..c6e3748
--- /dev/null
+++ b/src/shared/evidence-set.ts
@@ -0,0 +1,36 @@
+/**
+ * EvidenceSet — an ordered group of evidence items pointed at a target.
+ *
+ * Implements `wiki/SharedContracts.md` §1 (vocabulary) and
+ * `wiki/ArchitectureOverview.md` §4.6.
+ *
+ * The set itself is target-shaped: it carries the `(targetType, targetId)`
+ * pair so the binder can answer "give me the EvidenceSet for this form
+ * field" in one call. `activeEvidenceItemId` is the membership of the
+ * set that the UI is currently focused on; cycling Tab/Shift-Tab through
+ * the field's chips updates it.
+ */
+
+import type { EvidenceItemId, EvidenceSetId } from "./ids";
+import type { EvidenceTargetType } from "./evidence-link";
+
+export interface EvidenceSet {
+ readonly id: EvidenceSetId;
+ readonly label?: string;
+ /**
+ * Optional target binding. Form-field sets always carry these; ad-hoc
+ * topical sets may leave them undefined.
+ */
+ readonly targetType?: EvidenceTargetType;
+ readonly targetId?: string;
+ /**
+ * Membership in display order. The binder is free to reorder, but
+ * persistence preserves this order so cycling is deterministic.
+ */
+ readonly evidenceItemIds: readonly EvidenceItemId[];
+ /**
+ * The currently active member, or undefined if the set is empty or
+ * no member is yet focused.
+ */
+ readonly activeEvidenceItemId?: EvidenceItemId;
+}
diff --git a/src/shared/index.ts b/src/shared/index.ts
index 7bc6ad7..7d56802 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -3,4 +3,6 @@ export * from "./document";
export * from "./selector";
export * from "./annotation";
export * from "./evidence";
+export * from "./evidence-link";
+export * from "./evidence-set";
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
diff --git a/src/work/EngineContext.tsx b/src/work/EngineContext.tsx
index 558c55c..81a0783 100644
--- a/src/work/EngineContext.tsx
+++ b/src/work/EngineContext.tsx
@@ -217,3 +217,18 @@ export function useScrollToAnnotation(): {
scrollTo: ctx.scrollToAnnotation,
};
}
+
+/**
+ * Track the most-recent `EvidenceItemActivated` event id from the engine
+ * bus. Returns `null` until something is activated. UI components that
+ * highlight "the active evidence" subscribe via this hook so they don't
+ * need to import the binder's active-state machine directly.
+ */
+export function useLastActivatedEvidence(): import("@shared/ids").EvidenceItemId | null {
+ const engine = useEngine();
+ const [id, setId] = useState(null);
+ useEffect(() => {
+ return engine.bus.on("EvidenceItemActivated", (e) => setId(e.evidenceItemId));
+ }, [engine]);
+ return id;
+}
diff --git a/src/work/EvidenceSidebar.tsx b/src/work/EvidenceSidebar.tsx
index ed4806f..8745c1e 100644
--- a/src/work/EvidenceSidebar.tsx
+++ b/src/work/EvidenceSidebar.tsx
@@ -16,6 +16,7 @@ import {
useActiveDocument,
useEngine,
useEngineEventTick,
+ useLastActivatedEvidence,
useScrollToAnnotation,
} from "./EngineContext";
@@ -27,6 +28,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
const engine = useEngine();
const { document } = useActiveDocument();
const { scrollTo } = useScrollToAnnotation();
+ const activeId = useLastActivatedEvidence();
// Refresh the list when items are created or updated. The tick values are
// included in the memo deps below so the list re-resolves on each event.
@@ -64,6 +66,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
const firstAnnotationId = item.annotationIds[0];
const annotation = firstAnnotationId ? engine.annotations.get(firstAnnotationId) : null;
const quote = annotation?.quote ?? "(no quote)";
+ const isActive = activeId === item.id;
return (
{
+ const original = await importOriginal();
+ const MockPdfSpikeViewer = (props: ViewerProps) => {
+ viewerSnapshot.pdfUrl = props.pdfUrl;
+ viewerSnapshot.scrollToAnnotationId = props.scrollToAnnotationId ?? null;
+ viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
+ viewerSnapshot.storedAnnotationIds = props.storedAnnotations.map((a) => a.id);
+ return (
+
+ );
+ };
+ return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
+});
+
+const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
+const SYNTHETIC_CANONICAL = [
+ "Pre quote.",
+ FIXTURE.known_good_quote,
+ "Post quote.",
+].join(" ");
+
+vi.mock("@source/index", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
+ const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
+ const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
+ const document: Document = {
+ id: documentId,
+ mediaType: "application/pdf",
+ ...(options?.filename ? { title: options.filename } : {}),
+ fingerprint: "synthetic-fingerprint-for-test",
+ createdAt: "2026-05-25T00:00:00.000Z",
+ updatedAt: "2026-05-25T00:00:00.000Z",
+ };
+ const representation: DocumentRepresentation = {
+ id: representationId,
+ documentId,
+ representationType: "pdf-text",
+ contentHash: "synthetic-fingerprint-for-test",
+ canonicalText: SYNTHETIC_CANONICAL,
+ pageMap: [{ page: 1, width: 595, height: 842 }],
+ offsetMap: [
+ {
+ page: 1,
+ globalStart: 0,
+ globalEnd: SYNTHETIC_CANONICAL.length,
+ pageLength: SYNTHETIC_CANONICAL.length,
+ },
+ ],
+ generatedAt: "2026-05-25T00:00:00.000Z",
+ };
+ return { document, representation };
+ }),
+ };
+});
+
+function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
+ return {
+ kind: "pdf",
+ text,
+ page,
+ rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
+ boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
+ };
+}
+
+async function loadApp() {
+ const { App } = await import("@app/App");
+ return render( );
+}
+
+function resetSnapshot() {
+ viewerSnapshot.pdfUrl = null;
+ viewerSnapshot.scrollToAnnotationId = null;
+ viewerSnapshot.onSelectionCaptured = null;
+ viewerSnapshot.storedAnnotationIds = [];
+}
+
+describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
+ beforeEach(() => {
+ resetSnapshot();
+ globalThis.localStorage?.clear();
+ globalThis.fetch = vi.fn(async () =>
+ new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
+ status: 200,
+ headers: { "Content-Type": "application/pdf" },
+ }),
+ );
+ if (typeof window !== "undefined") {
+ history.replaceState(null, "", window.location.pathname);
+ }
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+ });
+
+ it("focusing a linked field auto-activates the first evidence and bridges to viewer scroll", { timeout: 15000 }, async () => {
+ const user = userEvent.setup();
+ await loadApp();
+
+ // --- Review mode: create an evidence item via the captured-selection flow.
+ const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
+ await user.click(fixtureBtn);
+ await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
+ await act(async () => {
+ viewerSnapshot.onSelectionCaptured!(
+ syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
+ [{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
+ );
+ });
+ const textarea = screen.getByPlaceholderText(/Add a one-line comment/);
+ await user.type(textarea, "Form-cycling test evidence");
+ await user.click(screen.getByRole("button", { name: /Save evidence/ }));
+ await screen.findByText(/Form-cycling test evidence/);
+
+ // --- Switch to Forms mode.
+ await user.click(screen.getByRole("button", { name: "Forms" }));
+
+ // The evidence should appear in the Forms strip too (it queries by doc).
+ const stripCard = await screen.findByRole("button", {
+ name: /Form-cycling test evidence/,
+ });
+
+ // Stage it.
+ await user.click(stripCard);
+
+ // Click the Summary field → link gets created.
+ const summaryField = screen.getByLabelText("Summary of the matter");
+ await user.click(summaryField);
+
+ // Link chip on Summary now shows "1 evidence"
+ await waitFor(
+ () => {
+ expect(screen.queryByTestId("field-summary-chip")).not.toBeNull();
+ },
+ { timeout: 4000 },
+ );
+
+ // Resetting the previous scrollToAnnotationId so we can detect a *new*
+ // scroll triggered by chip auto-activation.
+ viewerSnapshot.scrollToAnnotationId = null;
+
+ // Click the Summary field again — this re-focuses the target. The chip
+ // computed for it should now contain our evidence; the chips' auto-
+ // activation effect fires setActiveEvidence; ScrollBridge translates
+ // it to a viewer scroll.
+ //
+ // Note: clicking the same field doesn't fire onFocus if it's already
+ // focused. Move focus elsewhere first, then back.
+ await user.click(screen.getByLabelText("Disputed amount"));
+ await user.click(summaryField);
+
+ // The chip rendered inside the form pane has aria-current="true".
+ await waitFor(() => {
+ const chip = document.querySelector(
+ '[data-evidence-id][aria-current="true"]',
+ );
+ expect(chip).not.toBeNull();
+ });
+
+ // The viewer was asked to scroll to the underlying annotation.
+ await waitFor(() => {
+ expect(viewerSnapshot.scrollToAnnotationId).toMatch(/^ann_/);
+ });
+ });
+});
diff --git a/tests/integration/forms-link-flow.dom.test.tsx b/tests/integration/forms-link-flow.dom.test.tsx
new file mode 100644
index 0000000..398f59c
--- /dev/null
+++ b/tests/integration/forms-link-flow.dom.test.tsx
@@ -0,0 +1,184 @@
+/**
+ * CE-WP-0003-T05 integration — the side-by-side Forms layout +
+ * click-evidence-then-click-field linking interaction.
+ *
+ * Mirrors `app-prd-scenario.dom.test.tsx` (T09 of CE-WP-0002):
+ * - mocks `@anchor/index` to swap PdfSpikeViewer for an inert div
+ * - mocks `@source/index.ingestPdf` to skip PDF.js
+ *
+ * The flow:
+ * 1. Render , switch to Forms mode via the top-bar button.
+ * 2. Open the fixture (CollectionList click).
+ * 3. Seed an EvidenceItem directly via the engine (creating one through
+ * the UI requires Review mode and is exercised by T09).
+ * 4. Click the evidence card in the strip → staged for linking.
+ * 5. Click a form field → BindingService.linkEvidenceToTarget called.
+ * 6. The field's link-count chip shows "1 evidence".
+ */
+
+// @vitest-environment happy-dom
+
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Document, DocumentRepresentation } from "@shared/document";
+import type { AnnotationId, DocumentId, RepresentationId } from "@shared/ids";
+import type { Selector } from "@shared/selector";
+
+import type { PdfSelectionCapture } from "@anchor/index";
+import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
+
+interface ViewerProps {
+ pdfUrl: string;
+ storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
+ scrollToAnnotationId?: string;
+ onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
+}
+
+vi.mock("@anchor/index", async (importOriginal) => {
+ const original = await importOriginal();
+ const MockPdfSpikeViewer = (props: ViewerProps) => {
+ return (
+
+ );
+ };
+ return {
+ ...original,
+ PdfSpikeViewer: MockPdfSpikeViewer,
+ };
+});
+
+const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
+
+vi.mock("@source/index", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
+ const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
+ const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
+ const synthetic = "Synthetic canonical text for the form-link test.";
+ const document: Document = {
+ id: documentId,
+ mediaType: "application/pdf",
+ ...(options?.filename ? { title: options.filename } : {}),
+ fingerprint: "synthetic-fingerprint-for-test",
+ createdAt: "2026-05-25T00:00:00.000Z",
+ updatedAt: "2026-05-25T00:00:00.000Z",
+ };
+ const representation: DocumentRepresentation = {
+ id: representationId,
+ documentId,
+ representationType: "pdf-text",
+ contentHash: "synthetic-fingerprint-for-test",
+ canonicalText: synthetic,
+ pageMap: [{ page: 1, width: 595, height: 842 }],
+ offsetMap: [
+ { page: 1, globalStart: 0, globalEnd: synthetic.length, pageLength: synthetic.length },
+ ],
+ generatedAt: "2026-05-25T00:00:00.000Z",
+ };
+ return { document, representation };
+ }),
+ };
+});
+
+async function loadApp() {
+ const { App } = await import("@app/App");
+ return render( );
+}
+
+describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => {
+ beforeEach(() => {
+ globalThis.localStorage?.clear();
+ globalThis.fetch = vi.fn(async () =>
+ new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
+ status: 200,
+ headers: { "Content-Type": "application/pdf" },
+ }),
+ );
+ // Forms mode is hash-driven; make sure we start clean.
+ if (typeof window !== "undefined") {
+ history.replaceState(null, "", window.location.pathname);
+ }
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+ });
+
+ it("stages an evidence item then links it to the clicked field", async () => {
+ const user = userEvent.setup();
+ await loadApp();
+
+ // Switch to Forms via the top-bar button.
+ await user.click(screen.getByRole("button", { name: "Forms" }));
+
+ // The collection list is in the Forms layout too.
+ const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
+ await user.click(fixtureBtn);
+
+ // Wait for the fixture to load and the form to appear.
+ await waitFor(() => {
+ expect(screen.queryByLabelText("Summary of the matter")).not.toBeNull();
+ });
+
+ // Seed an EvidenceItem directly via the engine. We grab it through the
+ // EvidenceStrip empty-state lifecycle: capture an item via a mock
+ // dispatch. Since the engine is wrapped inside EngineProvider, we
+ // reach it by emitting a synthetic AnnotationCreated → EvidenceItem
+ // via window for testing isn't easy. Simpler: import the engine
+ // module directly and wire a parallel engine into the rendered app
+ // by patching localStorage. Even simpler for T05: drive the
+ // BindingService through its public API by talking to the engine
+ // through a getter we expose on window for tests.
+ //
+ // The smallest hack: drive engine.evidence.create + annotations.create
+ // by reaching through the engine instance the persister stores in
+ // localStorage. The persister key is "citation-evidence:engine-snapshot:v1".
+ // But the engine hasn't persisted yet — it has no events.
+ //
+ // The cleanest path: use a test-only window hook. We add it during
+ // the next iteration when wiring the active-cycling. For T05 the
+ // proof is the link-creation pipeline given a staged item — we
+ // dispatch the staged event manually with a synthetic id and verify
+ // that clicking a field triggers a link.
+ const SYNTHETIC_EV_ID = "ev_test_synthetic" as const;
+ await act(async () => {
+ window.dispatchEvent(
+ new CustomEvent("citation-evidence:staged-for-linking", {
+ detail: SYNTHETIC_EV_ID,
+ }),
+ );
+ });
+
+ // Click the Summary field → triggers FormFieldActivated → BindingService
+ // creates the link.
+ const summaryField = screen.getByLabelText("Summary of the matter");
+ await user.click(summaryField);
+
+ // The chip on the Summary field should now show 1 evidence.
+ await waitFor(() => {
+ expect(screen.queryByTestId("field-summary-chip")).not.toBeNull();
+ });
+ expect(screen.getByTestId("field-summary-chip").textContent).toMatch(/1 evidence/);
+ });
+
+ it("starts in Review mode by default and switches to Forms via hash", async () => {
+ await loadApp();
+ expect(screen.getByText("Collection")).toBeTruthy();
+ // Review pane's no-doc-open hint from EvidenceSidebar:
+ expect(screen.queryByText(/No document open/)).not.toBeNull();
+ // No demo form rendered yet
+ expect(screen.queryByText("Demo evidence-backed form")).toBeNull();
+ });
+});
+
+// Silence unused-import warnings for type-only imports referenced via JSX.
+void ((): AnnotationId | null => null);
diff --git a/tests/integration/forms-overlay-e2e.dom.test.tsx b/tests/integration/forms-overlay-e2e.dom.test.tsx
new file mode 100644
index 0000000..70bc255
--- /dev/null
+++ b/tests/integration/forms-overlay-e2e.dom.test.tsx
@@ -0,0 +1,218 @@
+/**
+ * CE-WP-0003-T08 — end-to-end test of the form-binding slice (PRD
+ * scenario steps 5-9 from CE-WP-0002-T09's continuation):
+ *
+ * 5. Navigate to /forms/demo (hash route).
+ * 6. Link the previously-created evidence item to the "summary" field.
+ * 7. Click the "summary" field.
+ * 8. The field, the evidence card, and the highlight all have
+ * aria-current="true".
+ * 9. The SVG visual-guide overlay contains exactly two elements
+ * (one field→card, one card→highlight).
+ *
+ * The viewer is mocked (same pattern as CE-WP-0002-T09) and
+ * `getHighlightClientRects` is stubbed to return a non-null DOMRect so
+ * the HighlightRectBridge can register a highlight rect — without that
+ * stub there is no real highlight DOM for the bridge to find.
+ *
+ * Step 10 (scroll → paths update next frame) is exercised by
+ * `Overlay.dom.test.tsx`'s invalidate-on-rect-change case; reproducing
+ * it through the full app stack would require driving the rAF scheduler
+ * deterministically, which is overkill given the dedicated unit test.
+ */
+
+// @vitest-environment happy-dom
+
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Document, DocumentRepresentation } from "@shared/document";
+import type { DocumentId, RepresentationId } from "@shared/ids";
+import type { Selector } from "@shared/selector";
+
+import type { PdfSelectionCapture } from "@anchor/index";
+import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
+
+interface ViewerProps {
+ pdfUrl: string;
+ storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
+ scrollToAnnotationId?: string;
+ onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
+}
+
+interface ViewerSnapshot {
+ pdfUrl: string | null;
+ scrollToAnnotationId: string | null;
+ onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null;
+}
+
+const viewerSnapshot: ViewerSnapshot = {
+ pdfUrl: null,
+ scrollToAnnotationId: null,
+ onSelectionCaptured: null,
+};
+
+function fakeHighlightRect(): DOMRect {
+ // happy-dom may not expose a stable DOMRect constructor; use a literal.
+ return {
+ x: 700, y: 400, width: 200, height: 20,
+ top: 400, left: 700, right: 900, bottom: 420,
+ toJSON() { return {}; },
+ } as DOMRect;
+}
+
+vi.mock("@anchor/index", async (importOriginal) => {
+ const original = await importOriginal();
+ const MockPdfSpikeViewer = (props: ViewerProps) => {
+ viewerSnapshot.pdfUrl = props.pdfUrl;
+ viewerSnapshot.scrollToAnnotationId = props.scrollToAnnotationId ?? null;
+ viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
+ return
;
+ };
+ return {
+ ...original,
+ PdfSpikeViewer: MockPdfSpikeViewer,
+ // Always return a non-null rect so the HighlightRectBridge has
+ // something to register and the Overlay can draw the second leg.
+ getHighlightClientRects: vi.fn(() => fakeHighlightRect()),
+ };
+});
+
+const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
+const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
+
+vi.mock("@source/index", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
+ const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
+ const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
+ const document: Document = {
+ id: documentId,
+ mediaType: "application/pdf",
+ ...(options?.filename ? { title: options.filename } : {}),
+ fingerprint: "synthetic-fingerprint-for-test",
+ createdAt: "2026-05-25T00:00:00.000Z",
+ updatedAt: "2026-05-25T00:00:00.000Z",
+ };
+ const representation: DocumentRepresentation = {
+ id: representationId,
+ documentId,
+ representationType: "pdf-text",
+ contentHash: "synthetic-fingerprint-for-test",
+ canonicalText: SYNTHETIC_CANONICAL,
+ pageMap: [{ page: 1, width: 595, height: 842 }],
+ offsetMap: [
+ { page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
+ ],
+ generatedAt: "2026-05-25T00:00:00.000Z",
+ };
+ return { document, representation };
+ }),
+ };
+});
+
+function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
+ return {
+ kind: "pdf",
+ text,
+ page,
+ rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
+ boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
+ };
+}
+
+async function loadApp() {
+ const { App } = await import("@app/App");
+ return render( );
+}
+
+describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
+ beforeEach(() => {
+ viewerSnapshot.pdfUrl = null;
+ viewerSnapshot.scrollToAnnotationId = null;
+ viewerSnapshot.onSelectionCaptured = null;
+ globalThis.localStorage?.clear();
+ globalThis.fetch = vi.fn(async () =>
+ new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
+ status: 200,
+ headers: { "Content-Type": "application/pdf" },
+ }),
+ );
+ if (typeof window !== "undefined") {
+ history.replaceState(null, "", window.location.pathname);
+ }
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+ });
+
+ it(
+ "navigates to /forms/demo, links evidence, focuses field, asserts active triple + 2 SVG paths",
+ { timeout: 15000 },
+ async () => {
+ const user = userEvent.setup();
+ await loadApp();
+
+ // Steps 1-4 (CE-WP-0002 setup): create an evidence item in Review mode.
+ const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
+ await user.click(fixtureBtn);
+ await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
+ await act(async () => {
+ viewerSnapshot.onSelectionCaptured!(
+ syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
+ [{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
+ );
+ });
+ await user.type(
+ screen.getByPlaceholderText(/Add a one-line comment/),
+ "Overlay E2E evidence",
+ );
+ await user.click(screen.getByRole("button", { name: /Save evidence/ }));
+ await screen.findByText(/Overlay E2E evidence/);
+
+ // Step 5: navigate to /forms/demo via the top-bar.
+ await user.click(screen.getByRole("button", { name: "Forms" }));
+ expect(window.location.hash).toBe("#/forms/demo");
+
+ // Step 6: stage the evidence in the strip, then click the summary
+ // field to create the link.
+ const stripCard = await screen.findByRole("button", {
+ name: /Overlay E2E evidence/,
+ });
+ await user.click(stripCard);
+ const summaryField = screen.getByLabelText("Summary of the matter");
+ await user.click(summaryField);
+
+ // Move focus elsewhere and back to re-fire focus on summary so that
+ // ActiveStateProvider triggers focus-target (the previous click that
+ // created the link consumed the staged state).
+ await user.click(screen.getByLabelText("Disputed amount"));
+ await user.click(summaryField);
+
+ // Step 8: aria-current on field row, chip, and (via the active
+ // state) the strip card.
+ await waitFor(() => {
+ const fieldRow = document.querySelector(
+ '[data-field-id="summary"][aria-current="true"]',
+ );
+ expect(fieldRow).not.toBeNull();
+ });
+ const activeChip = document.querySelector('[data-evidence-id][aria-current="true"]');
+ expect(activeChip).not.toBeNull();
+
+ // Step 9: SVG overlay renders 2 paths (field→card + card→highlight).
+ // HighlightRectBridge registers via the mocked getHighlightClientRects.
+ await waitFor(() => {
+ const svg = document.querySelector('[data-testid="visual-guide-overlay"]');
+ expect(svg).not.toBeNull();
+ expect(svg!.getAttribute("data-path-count")).toBe("2");
+ expect(svg!.querySelectorAll("path").length).toBe(2);
+ });
+ },
+ );
+});
diff --git a/workplans/CE-WP-0003-form-binding-visual-guide.md b/workplans/CE-WP-0003-form-binding-visual-guide.md
index 34a330c..6cdbcd1 100644
--- a/workplans/CE-WP-0003-form-binding-visual-guide.md
+++ b/workplans/CE-WP-0003-form-binding-visual-guide.md
@@ -8,10 +8,10 @@ repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
state_hub_workstream_id: 7b5b7235-57e3-4835-8fa6-376bb518fe2d
-status: todo
+status: done
owner: Bernd
created: 2026-05-24
-updated: 2026-05-24
+updated: 2026-05-25
depends_on_workplan: CE-WP-0002
spec_refs:
- wiki/ProductRequirementsDocument.md
@@ -58,7 +58,7 @@ T01 (EvidenceLink + EvidenceSet types + relation/status enums)
id: CE-WP-0003-T01
state_hub_task_id: 120b9b5a-9ca3-4dff-8c26-1b5c2e832dc4
priority: critical
-status: todo
+status: done
```
Add under `src/shared/`:
@@ -82,7 +82,7 @@ updating the doc, the test fails.
id: CE-WP-0003-T02
state_hub_task_id: f17e251d-4fd7-4ef3-b35c-e8e0dfb3a455
priority: high
-status: todo
+status: done
depends_on: [T01]
```
@@ -106,7 +106,7 @@ Emit the events from SharedContracts §4 (`EvidenceLinkCreated`,
id: CE-WP-0003-T03
state_hub_task_id: d3b853ef-7afe-491f-b40b-b6e980a23478
priority: critical
-status: todo
+status: done
depends_on: [T02]
```
@@ -134,7 +134,7 @@ it in T05/T06/T07.
id: CE-WP-0003-T04
state_hub_task_id: f42e1ecc-351c-4248-8872-1a25e79d3640
priority: medium
-status: todo
+status: done
depends_on: [T01]
```
@@ -150,7 +150,12 @@ type FormFieldSchema =
JSON Schema is **not** used yet — defer that to a later ADR. The MVP form
just needs to render 3-4 fields and accept evidence links.
-- `src/work/FormRenderer.tsx` renders the schema as a basic form
+- `src/binder/FormRenderer.tsx` renders the schema as a basic form
+ (relocated from `src/work/` per `wiki/DependencyMap.md` §2/§5 — `work`
+ cannot import `binder`, but FormRenderer needs `useRegisterRect` from
+ `binder/visual-guide`. The "evidence-backed form" composition belongs
+ in `binder/`; `app/` mounts both `work` panes and `binder` panes
+ side-by-side.)
- Each field registers itself with the rect registry as kind `"field"` with
the field's `id`
@@ -162,7 +167,7 @@ just needs to render 3-4 fields and accept evidence links.
id: CE-WP-0003-T05
state_hub_task_id: 100fb1ca-6168-4e5d-9dc5-f051e6f9ff61
priority: high
-status: todo
+status: done
depends_on: [T02, T04]
```
@@ -185,7 +190,7 @@ linked fields (e.g. a chip showing the count of linked evidence items).
id: CE-WP-0003-T06
state_hub_task_id: e3bdf1d3-c7a1-484c-8895-8d103e7f9fe6
priority: high
-status: todo
+status: done
depends_on: [T05]
```
@@ -209,7 +214,7 @@ Each evidence card registers itself with the rect registry as
id: CE-WP-0003-T07
state_hub_task_id: e2ec50be-d9c5-47dd-b801-9c1afb01e6fd
priority: high
-status: todo
+status: done
depends_on: [T03, T06]
```
@@ -237,7 +242,7 @@ registered.
id: CE-WP-0003-T08
state_hub_task_id: e6754c8e-f9e2-435a-af28-31a693c6d9a8
priority: high
-status: todo
+status: done
depends_on: [T05, T07]
```