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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user