diff --git a/src/anchor/index.ts b/src/anchor/index.ts index 9da25b4..497fa98 100644 --- a/src/anchor/index.ts +++ b/src/anchor/index.ts @@ -1,6 +1,7 @@ export * from "./types"; export { PdfSpikeViewer, + getHighlightClientRects, selectorsFromPdfCapture, type PdfSpikeViewerProps, type StoredAnnotation, diff --git a/src/anchor/pdf-viewer-adapter-spike.tsx b/src/anchor/pdf-viewer-adapter-spike.tsx index d12530d..50751fc 100644 --- a/src/anchor/pdf-viewer-adapter-spike.tsx +++ b/src/anchor/pdf-viewer-adapter-spike.tsx @@ -113,13 +113,50 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture { */ function SpikeHighlightContainer(): ReactNode { const { highlight, isScrolledTo } = useHighlightContainerContext(); + // Wrap the highlight in a data-tagged container so the visual-guide + // overlay's HighlightRectBridge can locate it via DOM query. The + // wrapper uses display: contents so it doesn't affect layout — the + // bounding rect is gathered from the live TextHighlight children at + // query time. return ( - - - +
+ + + +
); } +/** + * Resolve the rendered DOM rect for a highlight by data attribute, or + * `null` if the highlight isn't currently rendered (e.g. its page hasn't + * scrolled into view). Used by `app/forms/HighlightRectBridge` to feed + * the rect registry as kind="highlight". + * + * `display: contents` on the wrapper means it has no box of its own; we + * union the rects of its children. For TextHighlight that's typically + * one rect per line. + */ +export function getHighlightClientRects(annotationId: string): DOMRect | null { + if (typeof document === "undefined") return null; + const wrapper = document.querySelector(`[data-highlight-id="${CSS.escape(annotationId)}"]`); + if (!wrapper) return null; + const rects = wrapper.getClientRects(); + if (rects.length === 0) return null; + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + for (const r of Array.from(rects)) { + left = Math.min(left, r.left); + top = Math.min(top, r.top); + right = Math.max(right, r.right); + bottom = Math.max(bottom, r.bottom); + } + if (!isFinite(left)) return null; + return new DOMRect(left, top, right - left, bottom - top); +} + export interface PdfSpikeViewerProps { /** URL of the PDF to load (served by Vite dev server). */ readonly pdfUrl: string; diff --git a/src/app/App.tsx b/src/app/App.tsx index 996a98c..b907398 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,40 +1,136 @@ /** * App — the citation-evidence MVP shell. * - * Three-pane layout per `wiki/ArchitectureOverview.md` §12.1: + * Composes the two top-level layouts: * - * ┌────────────┬──────────────────┬────────────┐ - * │ Collection │ Document Viewer │ Evidence │ - * │ List │ │ Sidebar │ - * └────────────┴──────────────────┴────────────┘ + * - Review mode (CE-WP-0002): collection list / viewer / evidence sidebar. + * - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip, + * with click-to-link interaction. * - * CE-WP-0002-T06 stops at "viewer shell is rendered, evidence list is - * displayed". T07 wires the selection → annotation → evidence flow; T08 - * wires the sidebar-click → scroll-to-passage round-trip. + * Mode selection is driven by `location.hash`: `#/forms/demo` lands in + * Forms mode; anything else (including empty) lands in Review mode. The + * top bar toggles between them. We keep the hash sync so reload + deep + * links work; T08's E2E asserts the `/forms/demo` navigation path. + * + * Engine and binder providers are both mounted at the App root so + * evidence/annotations/links survive switching tabs. */ +import { useEffect, useState } from "react"; + +import { BinderProvider } from "@binder/index"; import { - CollectionList, EngineProvider, - EvidenceSidebar, - ViewerShell, + useEngine, } from "@work/index"; +import { FormsApp } from "./forms/FormsApp"; +import { ReviewLayout } from "./ReviewLayout"; + +type Mode = "review" | "forms"; + +const FORMS_HASH = "#/forms/demo"; + +function readModeFromHash(): Mode { + if (typeof window === "undefined") return "review"; + return window.location.hash === FORMS_HASH ? "forms" : "review"; +} + +function writeModeToHash(mode: Mode) { + if (typeof window === "undefined") return; + const target = mode === "forms" ? FORMS_HASH : ""; + if (window.location.hash !== target) { + if (target) { + window.location.hash = target; + } else { + // Clear hash without leaving "#" trailing in the URL bar. + history.replaceState(null, "", window.location.pathname + window.location.search); + } + } +} + +function ModeRouter() { + const [mode, setMode] = useState(() => readModeFromHash()); + + useEffect(() => { + function onHash() { + setMode(readModeFromHash()); + } + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + const handleModeChange = (next: Mode) => { + writeModeToHash(next); + setMode(next); + }; + + return ( +
+ +
+ {mode === "review" ? : } +
+
+ ); +} + +function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) { + return ( +
+ citation-evidence + + +
+ ); +} + +function tabStyle(active: boolean) { + return { + padding: "4px 12px", + fontSize: 12, + border: "1px solid #ccc", + borderBottom: active ? "2px solid #0050b3" : "1px solid #ccc", + background: active ? "#e8f0ff" : "white", + cursor: "pointer" as const, + }; +} + +function AppInner() { + const engine = useEngine(); + return ( + + + + ); +} + export function App() { return ( -
- - - -
+
); } diff --git a/src/app/ReviewLayout.tsx b/src/app/ReviewLayout.tsx new file mode 100644 index 0000000..92cc275 --- /dev/null +++ b/src/app/ReviewLayout.tsx @@ -0,0 +1,30 @@ +/** + * Review mode — the three-pane layout from CE-WP-0002-T06. + * + * ┌────────────┬──────────────────┬────────────┐ + * │ Collection │ Document Viewer │ Evidence │ + * │ List │ │ Sidebar │ + * └────────────┴──────────────────┴────────────┘ + */ + +import { + CollectionList, + EvidenceSidebar, + ViewerShell, +} from "@work/index"; + +export function ReviewLayout() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/forms/ActiveEvidenceChips.tsx b/src/app/forms/ActiveEvidenceChips.tsx new file mode 100644 index 0000000..df3dede --- /dev/null +++ b/src/app/forms/ActiveEvidenceChips.tsx @@ -0,0 +1,131 @@ +/** + * ActiveEvidenceChips — chip strip for the currently-focused field. + * + * Renders one chip per link on the active target. The chip: + * - is a focusable ` + ); +} + +export function ActiveEvidenceChips({ items }: ActiveEvidenceChipsProps) { + const { state, setActiveEvidence } = useActiveState(); + const targetKey = state.activeTarget + ? `${state.activeTarget.targetType}:${state.activeTarget.targetId}` + : null; + + // Auto-activate the first item whenever the active target changes and + // we have something to show. + useEffect(() => { + if (!targetKey) return; + if (items.length === 0) return; + if (state.activeEvidenceItemId) return; // already active + const first = items[0]!; + setActiveEvidence(first.evidenceItemId, first.annotationId); + }, [targetKey, items, state.activeEvidenceItemId, setActiveEvidence]); + + if (!state.activeTarget) return null; + if (items.length === 0) { + return ( +
+ No evidence linked to this field yet. +
+ ); + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx new file mode 100644 index 0000000..fa3702b --- /dev/null +++ b/src/app/forms/FormsApp.tsx @@ -0,0 +1,322 @@ +/** + * FormsApp — the evidence-backed form mode for CE-WP-0003. + * + * Layout: + * + * ┌────────────┬─────────────────┬─────────────┐ + * │ Collection │ FormRenderer │ ViewerShell │ + * │ │ (left) │ (right) │ + * ├────────────┴─────────────────┴─────────────┤ + * │ EvidenceStrip (bottom) │ + * └────────────────────────────────────────────┘ + * + * Linking interaction (T05): + * 1. User clicks an evidence card in the strip → it becomes "selected + * for linking" (highlighted; banner appears in the form pane). + * 2. User then clicks a form field. The field's `FormFieldActivated` + * event triggers `bindings.linkEvidenceToTarget(selected, field)`. + * 3. The selected-for-linking state clears; the field's link count + * chip increments. + * + * Active-evidence cycling (T06) and the visual guide overlay (T07) build + * on this composition without changing the link interaction. + */ + +import { useEffect, useMemo, useState } from "react"; + +import type { EvidenceItem } from "@shared/evidence"; +import type { AnnotationId, EvidenceItemId } from "@shared/ids"; + +import { Overlay, useActiveState, useBinder } from "@binder/index"; +import { + CollectionList, + ViewerShell, + useActiveDocument, + useEngine, + useEngineEventTick, + useScrollToAnnotation, +} from "@work/index"; + +import { FormRenderer } from "@binder/FormRenderer"; + +import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips"; +import { DEMO_SCHEMA } from "./demo-schema"; +import { HighlightRectBridge } from "./HighlightRectBridge"; + +export function FormsApp() { + return ( +
+
+ + + +
+ + + + +
+ ); +} + +/** + * Bridge: the binder's active-state machine doesn't know how to scroll + * the viewer; the work-level `useScrollToAnnotation` does. This subscriber + * reads `activeAnnotationId` from the binder and calls `scrollTo` whenever + * it changes. Lives in app/ because that's where both subsystems meet. + */ +function ScrollBridge() { + const { state } = useActiveState(); + const { scrollTo } = useScrollToAnnotation(); + useEffect(() => { + if (state.activeAnnotationId) { + scrollTo(state.activeAnnotationId); + } + }, [state.activeAnnotationId, scrollTo]); + return null; +} + +function FormPane() { + const { document } = useActiveDocument(); + const { bindings } = useBinder(); + const engine = useEngine(); + const linkTick = useEngineEventTick("EvidenceLinkCreated"); + const { state: activeState } = useActiveState(); + + const [selectedForLinking, setSelectedForLinking] = useState( + null, + ); + + // Compute per-field link counts. Re-derives on link create. + const linkCounts = useMemo>(() => { + const out: Record = {}; + for (const field of DEMO_SCHEMA.fields) { + out[field.id] = bindings.listEvidenceForTarget({ + targetType: "form-field", + targetId: field.id, + }).length; + } + void linkTick; + return out; + }, [bindings, linkTick]); + + // Compute chip items for the currently-active target. + const activeChipItems = useMemo(() => { + if (!activeState.activeTarget) return []; + const links = bindings.listEvidenceForTarget(activeState.activeTarget); + return links + .map((link): ActiveEvidenceChipsItem | null => { + const item = engine.evidence.get(link.evidenceItemId); + if (!item) return null; + const annotationId: AnnotationId | null = item.annotationIds[0] ?? null; + const annotation = annotationId ? engine.annotations.get(annotationId) : null; + return { + evidenceItemId: link.evidenceItemId, + annotationId, + quote: annotation?.quote ?? "(no quote)", + ...(item.commentary ? { commentary: item.commentary } : {}), + }; + }) + .filter((c): c is ActiveEvidenceChipsItem => c !== null); + // linkTick is included so newly created links populate the chips + // without an explicit refresh. + }, [activeState.activeTarget, bindings, engine, linkTick]); + + // Listen for FormFieldActivated and, if an evidence is staged, create + // the link. The state machine reduction (focus-target) happens in + // parallel via ActiveStateProvider's own handler. + useEffect(() => { + return engine.bus.on("FormFieldActivated", (event) => { + if (!selectedForLinking) return; + bindings.linkEvidenceToTarget({ + evidenceItemId: selectedForLinking, + target: event.target, + }); + setSelectedForLinking(null); + }); + }, [engine, bindings, selectedForLinking]); + + return ( +
+ setSelectedForLinking(null)} + /> + {document ? ( + <> + + + + ) : ( + + )} + +
+ ); +} + +function EmptyHint() { + return ( +

+ Pick a fixture from the collection list to start binding evidence. +

+ ); +} + +function SelectedBanner({ + selectedForLinking, + onClear, +}: { + selectedForLinking: EvidenceItemId | null; + onClear: () => void; +}) { + if (!selectedForLinking) return null; + return ( +
+ + Evidence staged for linking. Click a form field to link it, or{" "} + + +
+ ); +} + +/** + * Bridges the strip's "stage this evidence" callback to the FormPane's + * local state. The strip lives in a sibling DOM subtree; rather than + * lifting `selectedForLinking` all the way up to FormsApp, we publish a + * setter into a module-scoped event target. + * + * Simpler than another context for one local handshake. + */ +const STAGED_EVENT = "citation-evidence:staged-for-linking"; + +function SelectionContext({ + setSelected, +}: { + setSelected: (id: EvidenceItemId | null) => void; +}) { + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + setSelected(detail); + }; + window.addEventListener(STAGED_EVENT, handler); + return () => window.removeEventListener(STAGED_EVENT, handler); + }, [setSelected]); + return null; +} + +export function publishStagedForLinking(id: EvidenceItemId | null) { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id })); +} + +function EvidenceStrip() { + const engine = useEngine(); + const { document } = useActiveDocument(); + const createTick = useEngineEventTick("EvidenceItemCreated"); + const updateTick = useEngineEventTick("EvidenceItemUpdated"); + const linkTick = useEngineEventTick("EvidenceLinkCreated"); + const { state: activeState } = useActiveState(); + const [stagedId, setStagedId] = useState(null); + + const items = useMemo(() => { + if (!document) return []; + void createTick; + void updateTick; + void linkTick; + return engine.evidence.listByDocument(document.id); + }, [document, engine, createTick, updateTick, linkTick]); + + const handleStage = (id: EvidenceItemId) => { + const next = stagedId === id ? null : id; + setStagedId(next); + publishStagedForLinking(next); + }; + + if (!document) return null; + + return ( +
+ {items.length === 0 && ( +

+ No evidence yet. Switch to Review mode to capture a passage. +

+ )} + {items.map((item) => { + const firstAnn = item.annotationIds[0] + ? engine.annotations.get(item.annotationIds[0]) + : null; + const quote = firstAnn?.quote ?? "(no quote)"; + const isStaged = stagedId === item.id; + const isActive = activeState.activeEvidenceItemId === item.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/forms/HighlightRectBridge.tsx b/src/app/forms/HighlightRectBridge.tsx new file mode 100644 index 0000000..5c9c061 --- /dev/null +++ b/src/app/forms/HighlightRectBridge.tsx @@ -0,0 +1,46 @@ +/** + * HighlightRectBridge — wires the viewer's rendered highlight DOM into + * the binder's rect registry as `kind="highlight"`. + * + * The viewer adapter exposes `getHighlightClientRects(annotationId)` + * (CE-WP-0003-T07) which returns the live bounding rect of a highlight + * by data attribute. We register a lazy callback that re-runs that + * lookup on every `rect-changed` event from the scroll/resize pump, so + * even as the user scrolls, the registered rect tracks the visible + * position. + * + * Lives in app/ because it spans: + * - binder (rect registry) + * - work (active document, scroll bridge) + * - anchor (the DOM-query helper) + * + * If the active annotation isn't currently rendered (its page is off + * screen, or no highlight matched), the callback returns null and the + * overlay omits the card→highlight curve until it becomes visible. + */ + +import { useEffect } from "react"; + +import { getHighlightClientRects } from "@anchor/index"; +import { + useActiveState, + useRectRegistryContext, +} from "@binder/index"; + +export function HighlightRectBridge() { + const { state } = useActiveState(); + const { registry } = useRectRegistryContext(); + + useEffect(() => { + const annotationId = state.activeAnnotationId; + if (!annotationId) return; + const unregister = registry.register( + "highlight", + annotationId, + () => getHighlightClientRects(annotationId), + ); + return unregister; + }, [state.activeAnnotationId, registry]); + + return null; +} diff --git a/src/app/forms/demo-schema.ts b/src/app/forms/demo-schema.ts new file mode 100644 index 0000000..386f2f2 --- /dev/null +++ b/src/app/forms/demo-schema.ts @@ -0,0 +1,29 @@ +/** + * Demo form schema for CE-WP-0003 (the form-binding slice). + * + * Deliberately minimal: text, textarea, date. JSON Schema is **not** used + * here — that's deferred to a later ADR. The MVP form's only job is to + * render a handful of fields and accept evidence links so the visual-guide + * round-trip can be exercised end-to-end. + */ + +export type FormFieldSchema = + | { readonly type: "text"; readonly id: string; readonly label: string } + | { readonly type: "textarea"; readonly id: string; readonly label: string } + | { readonly type: "date"; readonly id: string; readonly label: string }; + +export interface FormSchema { + readonly id: string; + readonly title: string; + readonly fields: readonly FormFieldSchema[]; +} + +export const DEMO_SCHEMA: FormSchema = { + id: "demo-form", + title: "Demo evidence-backed form", + fields: [ + { type: "textarea", id: "summary", label: "Summary of the matter" }, + { type: "date", id: "deadline", label: "Key deadline" }, + { type: "text", id: "amount", label: "Disputed amount" }, + ], +}; diff --git a/src/binder/BinderProvider.tsx b/src/binder/BinderProvider.tsx new file mode 100644 index 0000000..633cfb7 --- /dev/null +++ b/src/binder/BinderProvider.tsx @@ -0,0 +1,97 @@ +/** + * BinderProvider — composition root for the binder subsystem. + * + * Wires the four binder concerns (rect registry, binding service, link + * repo, active state machine) into one provider so a single mount inside + * the EngineProvider gives every binder consumer (FormRenderer, evidence + * picker, SVG overlay) what it needs. + * + * The provider is split out from the engine because in a future + * subsystem-extraction these will live in separate packages — the engine + * will publish only the event bus and the engine services, while + * `evidence-binder` will export this provider. + */ + +import { + createContext, + useContext, + useEffect, + useMemo, + type ReactNode, +} from "react"; + +import type { EventBus } from "@engine/events"; + +import { + ActiveStateProvider, + useActiveState, +} from "./state/active"; +import { + createInMemoryLinkRepo, + type EvidenceLinkRepository, +} from "./repos/in-memory-links"; +import { + createBindingService, + type BindingService, +} from "./services/bindings"; +import { + RectRegistryProvider, + createRectRegistryContextValue, + type RectRegistryContextValue, +} from "./visual-guide/react-hooks"; + +export interface BinderServices { + readonly links: EvidenceLinkRepository; + readonly bindings: BindingService; + readonly rect: RectRegistryContextValue; +} + +const BinderServicesContext = createContext(null); + +export function useBinder(): BinderServices { + const ctx = useContext(BinderServicesContext); + if (!ctx) throw new Error("useBinder: missing "); + return ctx; +} + +export interface BinderProviderProps { + readonly children: ReactNode; + /** + * The engine's event bus, threaded in by the composition root so the + * binder can emit §4 events without importing work/EngineContext + * (work cannot be a dependency of binder — see DependencyMap §2). + */ + readonly bus: EventBus; + /** + * Tests can inject a pre-built service set; production constructs a + * fresh one. The rect registry is *always* fresh per provider mount + * because its observers attach to the current `window`. + */ + readonly services?: Omit; +} + +export function BinderProvider({ children, bus, services }: BinderProviderProps) { + const built = useMemo(() => { + const links = services?.links ?? createInMemoryLinkRepo(); + const bindings = services?.bindings ?? createBindingService(links, bus); + const rect = createRectRegistryContextValue(); + return { links, bindings, rect }; + }, [bus, services]); + + // Disconnect rect observers + listeners on unmount. + useEffect(() => { + return () => { + built.rect.observer.disconnect(); + }; + }, [built.rect]); + + return ( + + + {children} + + + ); +} + +export { useActiveState }; diff --git a/src/binder/FormRenderer.dom.test.tsx b/src/binder/FormRenderer.dom.test.tsx new file mode 100644 index 0000000..a4da6fe --- /dev/null +++ b/src/binder/FormRenderer.dom.test.tsx @@ -0,0 +1,114 @@ +/** + * FormRenderer (CE-WP-0003-T04) — happy-dom test covering: + * - schema → DOM (3 demo fields render with their labels) + * - each field registers with rect registry as kind="field" + * - focusing a field calls activeState.focusTarget and emits FormFieldActivated + * - typing in a field invokes onValueChange + * - linkCounts shows the chip when > 0 + */ + +// @vitest-environment happy-dom + +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createEventBus, type EngineEvent } from "@engine/events"; + +import { FormRenderer, type FormSchema } from "./FormRenderer"; +import { + ActiveStateProvider, +} from "./state/active"; +import { + RectRegistryProvider, + createRectRegistryContextValue, +} from "./visual-guide/react-hooks"; + +const SCHEMA: FormSchema = { + id: "demo", + title: "Demo form", + fields: [ + { type: "textarea", id: "summary", label: "Summary" }, + { type: "date", id: "deadline", label: "Deadline" }, + { type: "text", id: "amount", label: "Amount" }, + ], +}; + +function renderWithProviders(props: Parameters[0]) { + const bus = createEventBus(); + const events: EngineEvent[] = []; + bus.onAny((e) => events.push(e)); + const ctxValue = createRectRegistryContextValue(); + const utils = render( + + + + + , + ); + return { ...utils, ctxValue, bus, events }; +} + +describe("FormRenderer (CE-WP-0003-T04)", () => { + let cleanupCtx: (() => void) | null = null; + beforeEach(() => { + cleanupCtx = null; + }); + afterEach(() => { + cleanupCtx?.(); + cleanup(); + }); + + it("renders each schema field with its label", () => { + renderWithProviders({ schema: SCHEMA }); + expect(screen.getByLabelText("Summary")).toBeTruthy(); + expect(screen.getByLabelText("Deadline")).toBeTruthy(); + expect(screen.getByLabelText("Amount")).toBeTruthy(); + }); + + it("registers each field with the rect registry as kind=field", () => { + const { ctxValue } = renderWithProviders({ schema: SCHEMA }); + cleanupCtx = () => ctxValue.observer.disconnect(); + const list = ctxValue.registry.list(); + expect(list).toHaveLength(3); + expect(list.every((r) => r.kind === "field")).toBe(true); + expect(list.map((r) => r.id).sort()).toEqual(["amount", "deadline", "summary"]); + }); + + it("focusing a field emits FormFieldActivated with the right target", async () => { + const user = userEvent.setup(); + const { events, ctxValue } = renderWithProviders({ schema: SCHEMA }); + cleanupCtx = () => ctxValue.observer.disconnect(); + await user.click(screen.getByLabelText("Summary")); + const fieldEvents = events.filter((e) => e.type === "FormFieldActivated"); + expect(fieldEvents).toHaveLength(1); + expect(fieldEvents[0]).toMatchObject({ + target: { targetType: "form-field", targetId: "summary" }, + }); + }); + + it("typing forwards onValueChange with the field id + new value", async () => { + const user = userEvent.setup(); + const changes: [string, string][] = []; + const { ctxValue } = renderWithProviders({ + schema: SCHEMA, + onValueChange: (id, value) => changes.push([id, value]), + }); + cleanupCtx = () => ctxValue.observer.disconnect(); + await user.type(screen.getByLabelText("Amount"), "42"); + expect(changes).toEqual([ + ["amount", "4"], + ["amount", "2"], + ]); + }); + + it("renders the link-count chip when linkCounts[fieldId] > 0", () => { + const { ctxValue } = renderWithProviders({ + schema: SCHEMA, + linkCounts: { summary: 2, amount: 0 }, + }); + cleanupCtx = () => ctxValue.observer.disconnect(); + expect(screen.queryByTestId("field-summary-chip")).not.toBeNull(); + expect(screen.queryByTestId("field-amount-chip")).toBeNull(); + }); +}); diff --git a/src/binder/FormRenderer.tsx b/src/binder/FormRenderer.tsx new file mode 100644 index 0000000..2d9364d --- /dev/null +++ b/src/binder/FormRenderer.tsx @@ -0,0 +1,161 @@ +/** + * FormRenderer — renders a FormSchema as a small evidence-backed form. + * + * Each field registers itself with the rect registry under + * `kind="field"` and the field's `id`, so the SVG visual guide (T07) can + * draw curves from the active field to its linked evidence card and on + * to the source highlight. + * + * Lives in `src/binder/` (not `src/work/`) because it depends on the + * rect-registry hooks in `binder/visual-guide`. See `wiki/DependencyMap.md` + * §2/§5 for the `work ⊄ binder` rule that motivates this placement. + * + * No styling beyond minimum legibility (workplan T06 note). Tailwind / + * design system can land later without changing the registry contract. + */ + +import { useRef, type ChangeEvent } from "react"; + +import type { EvidenceTarget } from "@shared/evidence-link"; + +import { useActiveState, type ActiveState } from "./state/active"; +import { useRegisterRect } from "./visual-guide/react-hooks"; + +function isFieldActive(state: ActiveState, fieldId: string): boolean { + return ( + state.activeTarget?.targetType === "form-field" && + state.activeTarget?.targetId === fieldId + ); +} + +export interface FormFieldSchema { + readonly type: "text" | "textarea" | "date"; + readonly id: string; + readonly label: string; +} + +export interface FormSchema { + readonly id: string; + readonly title: string; + readonly fields: readonly FormFieldSchema[]; +} + +export interface FormRendererProps { + readonly schema: FormSchema; + readonly values?: Readonly>; + readonly onValueChange?: (fieldId: string, value: string) => void; + /** + * Per-field annotation count. Rendered as a small chip beside the + * label so the user can tell which fields already have evidence. + */ + readonly linkCounts?: Readonly>; +} + +function FieldRow({ + field, + value, + linkCount, + isActive, + onChange, + onFocus, +}: { + field: FormFieldSchema; + value: string; + linkCount: number; + isActive: boolean; + onChange: (next: string) => void; + onFocus: () => void; +}) { + const ref = useRef(null); + useRegisterRect("field", field.id, ref); + + const sharedProps = { + id: `field-${field.id}`, + value, + onFocus, + onChange: (e: ChangeEvent) => + onChange(e.target.value), + style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 }, + }; + + return ( +
+ + {field.type === "textarea" ? ( +