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

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

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

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

119
src/BinderProvider.tsx Normal file
View file

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

117
src/FieldDefinitionForm.tsx Normal file
View file

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

View file

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

324
src/FormRenderer.tsx Normal file
View file

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

12
src/index.ts Normal file
View file

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

Binary file not shown.

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.