generated from coulomb/repo-seed
Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
- src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
- src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
- enum-conformance test parses SharedContracts.md and asserts the
runtime lists match exactly
T02 Binding service + in-memory link repo + active-state machine
- src/binder/repos/in-memory-links.ts: Map-backed EvidenceLinkRepository
- src/binder/services/bindings.ts: link/unlink/list/update/setActive
emitting §4 EvidenceLinkCreated / EvidenceLinkUpdated /
EvidenceItemActivated
- src/binder/state/active.ts: (target, evidence, annotation) reducer
+ ActiveStateProvider + useActiveState hook
- extended engine/events/types.ts with EvidenceLinkCreated,
EvidenceLinkUpdated, FormFieldActivated payloads
T03 Rect registry (SharedContracts §7 — contract FROZEN)
- src/binder/visual-guide/rect-registry.ts: register/getRect/subscribe
+ invalidate + getVersion for useSyncExternalStore
- events.ts: scroll/resize/focus pumps via window + ResizeObserver +
IntersectionObserver, rAF-throttled
- react-hooks.ts: RectRegistryProvider, useRegisterRect(kind,id,ref),
useRectRegistryVersion
T04 Form schema + renderer
- src/app/forms/demo-schema.ts: text/textarea/date minimal schema
- src/binder/FormRenderer.tsx: renders schema, each field registers
as rect kind="field"; active field gets aria-current="true"
- placed in binder/ (not work/) because work cannot import binder per
DependencyMap.md §2 and the renderer needs the rect-registry hook;
workplan T04 was amended in-place to document this
T05 Side-by-side Forms layout + click-to-link
- src/app/forms/FormsApp.tsx + src/app/App.tsx top-bar router with
hash route #/forms/demo
- BinderProvider mounted at app root so links survive tab switching
- stage-evidence-then-click-field linking interaction with banner
+ per-field link-count chip
T06 Active-evidence cycling
- src/app/forms/ActiveEvidenceChips.tsx: chips per active target,
Tab cycles natively, first chip auto-activates on field focus,
each chip registers as rect kind="evidence-card"
- ScrollBridge in FormsApp wires activeAnnotationId to viewer scroll
- EvidenceSidebar + EvidenceStrip highlight the active item via the
new useLastActivatedEvidence hook in work/EngineContext
T07 SVG visual-guide overlay
- src/binder/visual-guide/Overlay.tsx: single fixed-positioned SVG,
draws field→card and card→highlight bezier curves for the active
triple, rAF-throttled via the registry
- src/anchor exposes getHighlightClientRects(annotationId); the
spike viewer wraps highlights in [data-highlight-id] so the helper
can locate them
- src/app/forms/HighlightRectBridge.tsx: registers the active
annotation's rect via that helper
T08 End-to-end test (PRD scenario steps 5-9)
- tests/integration/forms-overlay-e2e.dom.test.tsx: full path from
Review-mode capture through Forms-mode link to active triple +
aria-current assertions + 2 SVG paths in the overlay
- additional integration coverage: forms-link-flow + forms-active-cycling
Gates: typecheck ✓ · lint ✓ · build ✓ · 152/152 tests across 21 files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
export * from "./types";
|
export * from "./types";
|
||||||
export {
|
export {
|
||||||
PdfSpikeViewer,
|
PdfSpikeViewer,
|
||||||
|
getHighlightClientRects,
|
||||||
selectorsFromPdfCapture,
|
selectorsFromPdfCapture,
|
||||||
type PdfSpikeViewerProps,
|
type PdfSpikeViewerProps,
|
||||||
type StoredAnnotation,
|
type StoredAnnotation,
|
||||||
|
|||||||
@@ -113,13 +113,50 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
|
|||||||
*/
|
*/
|
||||||
function SpikeHighlightContainer(): ReactNode {
|
function SpikeHighlightContainer(): ReactNode {
|
||||||
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
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 (
|
return (
|
||||||
<MonitoredHighlightContainer>
|
<div data-highlight-id={highlight.id} style={{ display: "contents" }}>
|
||||||
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
<MonitoredHighlightContainer>
|
||||||
</MonitoredHighlightContainer>
|
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
||||||
|
</MonitoredHighlightContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export interface PdfSpikeViewerProps {
|
||||||
/** URL of the PDF to load (served by Vite dev server). */
|
/** URL of the PDF to load (served by Vite dev server). */
|
||||||
readonly pdfUrl: string;
|
readonly pdfUrl: string;
|
||||||
|
|||||||
142
src/app/App.tsx
142
src/app/App.tsx
@@ -1,40 +1,136 @@
|
|||||||
/**
|
/**
|
||||||
* App — the citation-evidence MVP shell.
|
* App — the citation-evidence MVP shell.
|
||||||
*
|
*
|
||||||
* Three-pane layout per `wiki/ArchitectureOverview.md` §12.1:
|
* Composes the two top-level layouts:
|
||||||
*
|
*
|
||||||
* ┌────────────┬──────────────────┬────────────┐
|
* - Review mode (CE-WP-0002): collection list / viewer / evidence sidebar.
|
||||||
* │ Collection │ Document Viewer │ Evidence │
|
* - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip,
|
||||||
* │ List │ │ Sidebar │
|
* with click-to-link interaction.
|
||||||
* └────────────┴──────────────────┴────────────┘
|
|
||||||
*
|
*
|
||||||
* CE-WP-0002-T06 stops at "viewer shell is rendered, evidence list is
|
* Mode selection is driven by `location.hash`: `#/forms/demo` lands in
|
||||||
* displayed". T07 wires the selection → annotation → evidence flow; T08
|
* Forms mode; anything else (including empty) lands in Review mode. The
|
||||||
* wires the sidebar-click → scroll-to-passage round-trip.
|
* 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 {
|
import {
|
||||||
CollectionList,
|
|
||||||
EngineProvider,
|
EngineProvider,
|
||||||
EvidenceSidebar,
|
useEngine,
|
||||||
ViewerShell,
|
|
||||||
} from "@work/index";
|
} 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<Mode>(() => readModeFromHash());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onHash() {
|
||||||
|
setMode(readModeFromHash());
|
||||||
|
}
|
||||||
|
window.addEventListener("hashchange", onHash);
|
||||||
|
return () => window.removeEventListener("hashchange", onHash);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleModeChange = (next: Mode) => {
|
||||||
|
writeModeToHash(next);
|
||||||
|
setMode(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
|
||||||
|
<TopBar mode={mode} onModeChange={handleModeChange} />
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
{mode === "review" ? <ReviewLayout /> : <FormsApp />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderBottom: "1px solid #ddd",
|
||||||
|
background: "#fafafa",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
|
||||||
|
<button
|
||||||
|
onClick={() => onModeChange("review")}
|
||||||
|
aria-pressed={mode === "review"}
|
||||||
|
style={tabStyle(mode === "review")}
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onModeChange("forms")}
|
||||||
|
aria-pressed={mode === "forms"}
|
||||||
|
style={tabStyle(mode === "forms")}
|
||||||
|
>
|
||||||
|
Forms
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<BinderProvider bus={engine.bus}>
|
||||||
|
<ModeRouter />
|
||||||
|
</BinderProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<EngineProvider>
|
<EngineProvider>
|
||||||
<div
|
<AppInner />
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
height: "100vh",
|
|
||||||
fontFamily: "system-ui, sans-serif",
|
|
||||||
color: "#222",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CollectionList />
|
|
||||||
<ViewerShell />
|
|
||||||
<EvidenceSidebar />
|
|
||||||
</div>
|
|
||||||
</EngineProvider>
|
</EngineProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/app/ReviewLayout.tsx
Normal file
30
src/app/ReviewLayout.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: "100%",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CollectionList />
|
||||||
|
<ViewerShell />
|
||||||
|
<EvidenceSidebar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/app/forms/ActiveEvidenceChips.tsx
Normal file
131
src/app/forms/ActiveEvidenceChips.tsx
Normal file
@@ -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 `<button>` 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<HTMLButtonElement>(null);
|
||||||
|
useRegisterRect("evidence-card", item.evidenceItemId, ref);
|
||||||
|
const { setActiveEvidence } = useActiveState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
onFocus={() => 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontStyle: "italic", marginBottom: 2 }}>
|
||||||
|
“{item.quote.slice(0, 80)}
|
||||||
|
{item.quote.length > 80 ? "…" : ""}”
|
||||||
|
</div>
|
||||||
|
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
padding: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#666",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No evidence linked to this field yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Evidence for active field"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
padding: 6,
|
||||||
|
borderTop: "1px dashed #ccc",
|
||||||
|
background: "#fdfdfd",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Chip
|
||||||
|
key={item.evidenceItemId}
|
||||||
|
item={item}
|
||||||
|
isActive={state.activeEvidenceItemId === item.evidenceItemId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
src/app/forms/FormsApp.tsx
Normal file
322
src/app/forms/FormsApp.tsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
|
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||||
|
<CollectionList />
|
||||||
|
<FormPane />
|
||||||
|
<ViewerShell />
|
||||||
|
</div>
|
||||||
|
<EvidenceStrip />
|
||||||
|
<ScrollBridge />
|
||||||
|
<HighlightRectBridge />
|
||||||
|
<Overlay />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<EvidenceItemId | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute per-field link counts. Re-derives on link create.
|
||||||
|
const linkCounts = useMemo<Record<string, number>>(() => {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
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<readonly ActiveEvidenceChipsItem[]>(() => {
|
||||||
|
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 (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
flex: "1 1 0",
|
||||||
|
minWidth: 320,
|
||||||
|
borderRight: "1px solid #ddd",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectedBanner
|
||||||
|
selectedForLinking={selectedForLinking}
|
||||||
|
onClear={() => setSelectedForLinking(null)}
|
||||||
|
/>
|
||||||
|
{document ? (
|
||||||
|
<>
|
||||||
|
<FormRenderer schema={DEMO_SCHEMA} linkCounts={linkCounts} />
|
||||||
|
<ActiveEvidenceChips items={activeChipItems} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyHint />
|
||||||
|
)}
|
||||||
|
<SelectionContext setSelected={setSelectedForLinking} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHint() {
|
||||||
|
return (
|
||||||
|
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
Pick a fixture from the collection list to start binding evidence.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedBanner({
|
||||||
|
selectedForLinking,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
selectedForLinking: EvidenceItemId | null;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
if (!selectedForLinking) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
background: "#fff4d6",
|
||||||
|
borderBottom: "1px solid #f0c040",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
fontSize: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Evidence staged for linking. Click a form field to link it, or{" "}
|
||||||
|
</span>
|
||||||
|
<button onClick={onClear} style={{ fontSize: 12, padding: "2px 8px" }}>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<EvidenceItemId | null>).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<EvidenceItemId | null>(null);
|
||||||
|
|
||||||
|
const items = useMemo<readonly EvidenceItem[]>(() => {
|
||||||
|
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 (
|
||||||
|
<section
|
||||||
|
aria-label="Evidence list"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid #ddd",
|
||||||
|
background: "#fafafa",
|
||||||
|
padding: 8,
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
overflowX: "auto",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
minHeight: 100,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
|
||||||
|
No evidence yet. Switch to Review mode to capture a passage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
||||||
|
“{quote.slice(0, 100)}
|
||||||
|
{quote.length > 100 ? "…" : ""}”
|
||||||
|
</div>
|
||||||
|
{item.commentary && (
|
||||||
|
<div style={{ color: "#333" }}>{item.commentary}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/forms/HighlightRectBridge.tsx
Normal file
46
src/app/forms/HighlightRectBridge.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
29
src/app/forms/demo-schema.ts
Normal file
29
src/app/forms/demo-schema.ts
Normal file
@@ -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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
97
src/binder/BinderProvider.tsx
Normal file
97
src/binder/BinderProvider.tsx
Normal file
@@ -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<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">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BinderProvider({ children, bus, services }: 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]);
|
||||||
|
|
||||||
|
// 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 };
|
||||||
114
src/binder/FormRenderer.dom.test.tsx
Normal file
114
src/binder/FormRenderer.dom.test.tsx
Normal 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 "@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<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
src/binder/FormRenderer.tsx
Normal file
161
src/binder/FormRenderer.tsx
Normal file
@@ -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<Record<string, string>>;
|
||||||
|
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<Record<string, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
useRegisterRect("field", field.id, ref);
|
||||||
|
|
||||||
|
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={{
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isActive ? "#e8f0ff" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={sharedProps.id}
|
||||||
|
style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
{linkCount > 0 ? (
|
||||||
|
<span
|
||||||
|
data-testid={`field-${field.id}-chip`}
|
||||||
|
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,
|
||||||
|
}: FormRendererProps) {
|
||||||
|
const { state, focusTarget } = useActiveState();
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
||||||
|
focusTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-form-id={schema.id}
|
||||||
|
style={{ padding: 12 }}
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: 14, marginTop: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{schema.title}
|
||||||
|
</h2>
|
||||||
|
{schema.fields.map((field) => (
|
||||||
|
<FieldRow
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={values?.[field.id] ?? ""}
|
||||||
|
linkCount={linkCounts?.[field.id] ?? 0}
|
||||||
|
isActive={isFieldActive(state, field.id)}
|
||||||
|
onChange={(next) => onValueChange?.(field.id, next)}
|
||||||
|
onFocus={() => handleFocus(field.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
BIN
src/binder/repos/in-memory-links.ts
Normal file
BIN
src/binder/repos/in-memory-links.ts
Normal file
Binary file not shown.
1
src/binder/repos/index.ts
Normal file
1
src/binder/repos/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./in-memory-links";
|
||||||
180
src/binder/services/bindings.test.ts
Normal file
180
src/binder/services/bindings.test.ts
Normal 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 "@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<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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/binder/services/bindings.ts
Normal file
114
src/binder/services/bindings.ts
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/binder/services/index.ts
Normal file
1
src/binder/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./bindings";
|
||||||
64
src/binder/state/active.test.ts
Normal file
64
src/binder/state/active.test.ts
Normal 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 "@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);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
src/binder/state/active.ts
Normal file
171
src/binder/state/active.ts
Normal file
@@ -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<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 clear = useCallback(() => {
|
||||||
|
dispatch({ type: "clear" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ActiveStateApi>(
|
||||||
|
() => ({ 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 <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/binder/state/index.ts
Normal file
1
src/binder/state/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./active";
|
||||||
150
src/binder/visual-guide/Overlay.dom.test.tsx
Normal file
150
src/binder/visual-guide/Overlay.dom.test.tsx
Normal 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 "@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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/binder/visual-guide/Overlay.tsx
Normal file
115
src/binder/visual-guide/Overlay.tsx
Normal file
@@ -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<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(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 (
|
||||||
|
<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/binder/visual-guide/events.ts
Normal file
118
src/binder/visual-guide/events.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/binder/visual-guide/index.ts
Normal file
4
src/binder/visual-guide/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./rect-registry";
|
||||||
|
export * from "./events";
|
||||||
|
export * from "./react-hooks";
|
||||||
|
export { Overlay, type OverlayProps } from "./Overlay";
|
||||||
152
src/binder/visual-guide/react-hooks.dom.test.tsx
Normal file
152
src/binder/visual-guide/react-hooks.dom.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/binder/visual-guide/react-hooks.ts
Normal file
98
src/binder/visual-guide/react-hooks.ts
Normal 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);
|
||||||
|
}
|
||||||
151
src/binder/visual-guide/rect-registry.test.ts
Normal file
151
src/binder/visual-guide/rect-registry.test.ts
Normal 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" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
src/binder/visual-guide/rect-registry.ts
Normal file
BIN
src/binder/visual-guide/rect-registry.ts
Normal file
Binary file not shown.
@@ -11,10 +11,15 @@
|
|||||||
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
|
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
|
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
|
||||||
|
import type {
|
||||||
|
EvidenceLink,
|
||||||
|
EvidenceTarget,
|
||||||
|
} from "@shared/evidence-link";
|
||||||
import type {
|
import type {
|
||||||
AnnotationId,
|
AnnotationId,
|
||||||
DocumentId,
|
DocumentId,
|
||||||
EvidenceItemId,
|
EvidenceItemId,
|
||||||
|
EvidenceLinkId,
|
||||||
RepresentationId,
|
RepresentationId,
|
||||||
} from "@shared/ids";
|
} from "@shared/ids";
|
||||||
|
|
||||||
@@ -69,6 +74,24 @@ export interface EvidenceItemActivatedEvent {
|
|||||||
readonly source?: "sidebar" | "form-field" | "citation-card";
|
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 =
|
export type EngineEvent =
|
||||||
| DocumentImportedEvent
|
| DocumentImportedEvent
|
||||||
| DocumentRepresentationGeneratedEvent
|
| DocumentRepresentationGeneratedEvent
|
||||||
@@ -77,7 +100,10 @@ export type EngineEvent =
|
|||||||
| AnnotationResolutionFailedEvent
|
| AnnotationResolutionFailedEvent
|
||||||
| EvidenceItemCreatedEvent
|
| EvidenceItemCreatedEvent
|
||||||
| EvidenceItemUpdatedEvent
|
| EvidenceItemUpdatedEvent
|
||||||
| EvidenceItemActivatedEvent;
|
| EvidenceItemActivatedEvent
|
||||||
|
| EvidenceLinkCreatedEvent
|
||||||
|
| EvidenceLinkUpdatedEvent
|
||||||
|
| FormFieldActivatedEvent;
|
||||||
|
|
||||||
export type EngineEventType = EngineEvent["type"];
|
export type EngineEventType = EngineEvent["type"];
|
||||||
|
|
||||||
|
|||||||
62
src/shared/evidence-link.test.ts
Normal file
62
src/shared/evidence-link.test.ts
Normal file
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/shared/evidence-link.ts
Normal file
107
src/shared/evidence-link.ts
Normal file
@@ -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<EvidenceLinkStatus, "no-evidence">;
|
||||||
|
|
||||||
|
/** 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",
|
||||||
|
];
|
||||||
36
src/shared/evidence-set.ts
Normal file
36
src/shared/evidence-set.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,4 +3,6 @@ export * from "./document";
|
|||||||
export * from "./selector";
|
export * from "./selector";
|
||||||
export * from "./annotation";
|
export * from "./annotation";
|
||||||
export * from "./evidence";
|
export * from "./evidence";
|
||||||
|
export * from "./evidence-link";
|
||||||
|
export * from "./evidence-set";
|
||||||
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
|
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
|
||||||
|
|||||||
@@ -217,3 +217,18 @@ export function useScrollToAnnotation(): {
|
|||||||
scrollTo: ctx.scrollToAnnotation,
|
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<import("@shared/ids").EvidenceItemId | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
return engine.bus.on("EvidenceItemActivated", (e) => setId(e.evidenceItemId));
|
||||||
|
}, [engine]);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
|
useLastActivatedEvidence,
|
||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const { document } = useActiveDocument();
|
const { document } = useActiveDocument();
|
||||||
const { scrollTo } = useScrollToAnnotation();
|
const { scrollTo } = useScrollToAnnotation();
|
||||||
|
const activeId = useLastActivatedEvidence();
|
||||||
|
|
||||||
// Refresh the list when items are created or updated. The tick values are
|
// 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.
|
// 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 firstAnnotationId = item.annotationIds[0];
|
||||||
const annotation = firstAnnotationId ? engine.annotations.get(firstAnnotationId) : null;
|
const annotation = firstAnnotationId ? engine.annotations.get(firstAnnotationId) : null;
|
||||||
const quote = annotation?.quote ?? "(no quote)";
|
const quote = annotation?.quote ?? "(no quote)";
|
||||||
|
const isActive = activeId === item.id;
|
||||||
return (
|
return (
|
||||||
<li key={item.id} style={{ marginBottom: 8 }}>
|
<li key={item.id} style={{ marginBottom: 8 }}>
|
||||||
<button
|
<button
|
||||||
@@ -72,12 +75,13 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
||||||
props.onActivate?.(item);
|
props.onActivate?.(item);
|
||||||
}}
|
}}
|
||||||
|
aria-current={isActive ? "true" : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
background: "#fff8d6",
|
background: isActive ? "#e8f0ff" : "#fff8d6",
|
||||||
border: "1px solid #e0c050",
|
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
|
||||||
padding: 8,
|
padding: 8,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export {
|
|||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useActiveDocumentId,
|
useActiveDocumentId,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
|
useLastActivatedEvidence,
|
||||||
usePendingSelection,
|
usePendingSelection,
|
||||||
|
useScrollToAnnotation,
|
||||||
type PendingSelection,
|
type PendingSelection,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
|
|||||||
224
tests/integration/forms-active-cycling.dom.test.tsx
Normal file
224
tests/integration/forms-active-cycling.dom.test.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* CE-WP-0003-T06 integration — focusing a field auto-activates its first
|
||||||
|
* linked evidence, the chip is marked aria-current, and the ScrollBridge
|
||||||
|
* fires scrollTo on the viewer with the right annotation.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Review mode: open fixture, inject a selection capture, save the
|
||||||
|
* evidence item (T09 pattern).
|
||||||
|
* 2. Switch to Forms mode.
|
||||||
|
* 3. Stage the saved evidence in the strip.
|
||||||
|
* 4. Click a field → BindingService creates a link.
|
||||||
|
* 5. Click the field again → ActiveStateProvider focuses target →
|
||||||
|
* chip list re-derives → auto-activates first chip → ScrollBridge
|
||||||
|
* calls scrollTo → mock viewer sees `scrollToAnnotationId`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @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;
|
||||||
|
storedAnnotationIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerSnapshot: ViewerSnapshot = {
|
||||||
|
pdfUrl: null,
|
||||||
|
scrollToAnnotationId: null,
|
||||||
|
onSelectionCaptured: null,
|
||||||
|
storedAnnotationIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@anchor/index", async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import("@anchor/index")>();
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
data-testid="mock-pdf-viewer"
|
||||||
|
data-pdf-url={props.pdfUrl}
|
||||||
|
data-scroll-to={props.scrollToAnnotationId ?? ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
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<typeof import("@source/index")>();
|
||||||
|
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(<App />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
184
tests/integration/forms-link-flow.dom.test.tsx
Normal file
184
tests/integration/forms-link-flow.dom.test.tsx
Normal file
@@ -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 <App />, 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<typeof import("@anchor/index")>();
|
||||||
|
const MockPdfSpikeViewer = (props: ViewerProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="mock-pdf-viewer"
|
||||||
|
data-pdf-url={props.pdfUrl}
|
||||||
|
data-stored-count={String(props.storedAnnotations.length)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
PdfSpikeViewer: MockPdfSpikeViewer,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||||
|
|
||||||
|
vi.mock("@source/index", async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import("@source/index")>();
|
||||||
|
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(<App />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
218
tests/integration/forms-overlay-e2e.dom.test.tsx
Normal file
218
tests/integration/forms-overlay-e2e.dom.test.tsx
Normal file
@@ -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 <path> 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<typeof import("@anchor/index")>();
|
||||||
|
const MockPdfSpikeViewer = (props: ViewerProps) => {
|
||||||
|
viewerSnapshot.pdfUrl = props.pdfUrl;
|
||||||
|
viewerSnapshot.scrollToAnnotationId = props.scrollToAnnotationId ?? null;
|
||||||
|
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
|
||||||
|
return <div data-testid="mock-pdf-viewer" />;
|
||||||
|
};
|
||||||
|
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<typeof import("@source/index")>();
|
||||||
|
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(<App />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,10 +8,10 @@ repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
|
|||||||
topic_slug: citation_evidence_mvp
|
topic_slug: citation_evidence_mvp
|
||||||
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
|
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
|
||||||
state_hub_workstream_id: 7b5b7235-57e3-4835-8fa6-376bb518fe2d
|
state_hub_workstream_id: 7b5b7235-57e3-4835-8fa6-376bb518fe2d
|
||||||
status: todo
|
status: done
|
||||||
owner: Bernd
|
owner: Bernd
|
||||||
created: 2026-05-24
|
created: 2026-05-24
|
||||||
updated: 2026-05-24
|
updated: 2026-05-25
|
||||||
depends_on_workplan: CE-WP-0002
|
depends_on_workplan: CE-WP-0002
|
||||||
spec_refs:
|
spec_refs:
|
||||||
- wiki/ProductRequirementsDocument.md
|
- wiki/ProductRequirementsDocument.md
|
||||||
@@ -58,7 +58,7 @@ T01 (EvidenceLink + EvidenceSet types + relation/status enums)
|
|||||||
id: CE-WP-0003-T01
|
id: CE-WP-0003-T01
|
||||||
state_hub_task_id: 120b9b5a-9ca3-4dff-8c26-1b5c2e832dc4
|
state_hub_task_id: 120b9b5a-9ca3-4dff-8c26-1b5c2e832dc4
|
||||||
priority: critical
|
priority: critical
|
||||||
status: todo
|
status: done
|
||||||
```
|
```
|
||||||
|
|
||||||
Add under `src/shared/`:
|
Add under `src/shared/`:
|
||||||
@@ -82,7 +82,7 @@ updating the doc, the test fails.
|
|||||||
id: CE-WP-0003-T02
|
id: CE-WP-0003-T02
|
||||||
state_hub_task_id: f17e251d-4fd7-4ef3-b35c-e8e0dfb3a455
|
state_hub_task_id: f17e251d-4fd7-4ef3-b35c-e8e0dfb3a455
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Emit the events from SharedContracts §4 (`EvidenceLinkCreated`,
|
|||||||
id: CE-WP-0003-T03
|
id: CE-WP-0003-T03
|
||||||
state_hub_task_id: d3b853ef-7afe-491f-b40b-b6e980a23478
|
state_hub_task_id: d3b853ef-7afe-491f-b40b-b6e980a23478
|
||||||
priority: critical
|
priority: critical
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T02]
|
depends_on: [T02]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ it in T05/T06/T07.
|
|||||||
id: CE-WP-0003-T04
|
id: CE-WP-0003-T04
|
||||||
state_hub_task_id: f42e1ecc-351c-4248-8872-1a25e79d3640
|
state_hub_task_id: f42e1ecc-351c-4248-8872-1a25e79d3640
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -150,7 +150,12 @@ type FormFieldSchema =
|
|||||||
JSON Schema is **not** used yet — defer that to a later ADR. The MVP form
|
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.
|
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
|
- Each field registers itself with the rect registry as kind `"field"` with
|
||||||
the field's `id`
|
the field's `id`
|
||||||
|
|
||||||
@@ -162,7 +167,7 @@ just needs to render 3-4 fields and accept evidence links.
|
|||||||
id: CE-WP-0003-T05
|
id: CE-WP-0003-T05
|
||||||
state_hub_task_id: 100fb1ca-6168-4e5d-9dc5-f051e6f9ff61
|
state_hub_task_id: 100fb1ca-6168-4e5d-9dc5-f051e6f9ff61
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T02, T04]
|
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
|
id: CE-WP-0003-T06
|
||||||
state_hub_task_id: e3bdf1d3-c7a1-484c-8895-8d103e7f9fe6
|
state_hub_task_id: e3bdf1d3-c7a1-484c-8895-8d103e7f9fe6
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T05]
|
depends_on: [T05]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,7 +214,7 @@ Each evidence card registers itself with the rect registry as
|
|||||||
id: CE-WP-0003-T07
|
id: CE-WP-0003-T07
|
||||||
state_hub_task_id: e2ec50be-d9c5-47dd-b801-9c1afb01e6fd
|
state_hub_task_id: e2ec50be-d9c5-47dd-b801-9c1afb01e6fd
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T03, T06]
|
depends_on: [T03, T06]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -237,7 +242,7 @@ registered.
|
|||||||
id: CE-WP-0003-T08
|
id: CE-WP-0003-T08
|
||||||
state_hub_task_id: e6754c8e-f9e2-435a-af28-31a693c6d9a8
|
state_hub_task_id: e6754c8e-f9e2-435a-af28-31a693c6d9a8
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T05, T07]
|
depends_on: [T05, T07]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user