diff --git a/history/2026-06-07-ecosystem-state-assessment.md b/history/2026-06-07-ecosystem-state-assessment.md new file mode 100644 index 0000000..ae8be1b --- /dev/null +++ b/history/2026-06-07-ecosystem-state-assessment.md @@ -0,0 +1,205 @@ +# Ecosystem State Assessment — citation-evidence family + +**Date:** 2026-06-07 +**Author:** Grok (Cursor), commissioned by Bernd +**Scope:** Review of all six `INTENT.md` files in the citation-evidence family, plus the +umbrella repo's code, workplans, wiki contracts, and test coverage — to assess current +state and recommend next steps. + +--- + +## 1. Family topology + +The citation-evidence ecosystem comprises **one umbrella repo and five subsystem repos**: + +```text +citation-evidence (umbrella — all MVP code lives here) + ├── citation-engine (domain model, services, persistence, rendering) + ├── evidence-anchor (selectors, resolution, viewer adapter contract) + ├── evidence-source (ingest, extraction, citation recovery) + ├── citation-work (review workspace UX) + └── evidence-binder (evidence-to-target binding, visual guide) +``` + +| Repo | Declared role | Actual state (2026-06-07) | +|------|---------------|---------------------------| +| **citation-evidence** | Umbrella product, contracts, reference app | **Active** — ~118 TS/TSX files, tests, workplans, wiki, ADRs | +| **citation-engine** | Domain model, services, persistence, rendering | **INTENT + README only** — code in `src/{shared,engine}/` | +| **evidence-anchor** | Selectors, resolution, viewer adapter | **INTENT + README only** — code in `src/anchor/` | +| **evidence-source** | Ingest, extraction, recovery | **INTENT + README only** — code in `src/source/` (PDF only) | +| **citation-work** | Review workspace UX | **INTENT + README only** — code in `src/work/` | +| **evidence-binder** | Evidence-to-target binding, visual guide | **INTENT + README only** — code in `src/binder/` | + +This is **intentional**, not neglect. On 2026-05-24 the family adopted an +**umbrella-first MVP** (ADR-0002 context, `INTENT.md` §MVP Strategy): prove the product +in one repo, then extract subsystems once boundaries are validated by real use. + +--- + +## 2. INTENT.md quality — design maturity is high + +All six `INTENT.md` files are coherent and mutually reinforcing. They share: + +- The same core flow: + `Document → DocumentRepresentation → Annotation → EvidenceItem → EvidenceLink → CitationCard` +- Explicit **in-scope / out-of-scope** boundaries (each repo pushes responsibilities outward) +- A consistent document shape (Purpose, Scope, Workflows, Success Criteria, Guiding Statement) +- A shared **"MVP Coordination — Code Lives Upstream"** section pointing at + `citation-evidence/wiki/` + +The umbrella `INTENT.md` is the strategic anchor: it owns shared contracts, integration, +and the reference scenario. Sister repos document *future* homes, not current code. + +### 2.1 Ambiguities from the original INTENTs — largely resolved + +The initial assessment (`history/2026-05-24-initial-assessment.md`) flagged overlapping +ownership (selectors, evidence states, viewer adapters, recovery). Those have since been +codified in: + +- `wiki/SharedContracts.md` — canonical enums, vocabulary, type/behavior split +- `wiki/DependencyMap.md` — allowed import edges, cycle prevention +- `docs/decisions/` — ADR-0004 (PDF viewer), ADR-0006 (selector ownership), + ADR-0005 (persistence), ADR-0007 (citation card format), ADR-0008 (session archive), etc. + +Notable reconciliations baked into sister INTENTs: + +- `strong-support` / `weak-support` / `contradicts` moved from `EvidenceItem.status` + to `EvidenceLink.relation` +- Selector **types** → engine; selector **algorithms** → anchor +- `citation-work` must not depend on `evidence-binder` (review works standalone; + forms compose both) + +--- + +## 3. Implementation state — MVP reference scenario is done + +Workplans **CE-WP-0001 through CE-WP-0005** are all `status: done`: + +| Workplan | Delivers | +|----------|----------| +| CE-WP-0001 | Scaffold, folder partitions, ESLint boundary rules, normalization, fixtures | +| CE-WP-0002 | PDF review slice — engine types, anchor, source ingest, viewer, sidebar | +| CE-WP-0003 | Form binding + visual guide (rect registry, SVG overlay) | +| CE-WP-0004 | Citation card export (Markdown + HTML) | +| CE-WP-0005 | Named sessions, arbitrary PDF upload, ZIP export/import | + +The PRD §20 reference scenario is covered end-to-end for **PDF**: + +1. Create collection/session +2. Upload PDF +3. Select passage → annotation → evidence item +4. Open side-by-side form +5. Link evidence to field +6. Focus field → coordinated highlight + visual guide +7. Export citation card + +Test coverage includes 7 integration tests (PRD scenario, forms flows, overlay, citation +export, session ZIP round-trip, anchor/source roundtrip) plus extensive unit tests per +subsystem folder. Recent git activity (June 2026) shows active polish on PDF text-layer +positioning and session UX. + +Boundary enforcement is real: `eslint-plugin-boundaries` guards the +`src/{shared,engine,anchor,source,binder,work,app}/` dependency graph described in +`DependencyMap.md`. + +--- + +## 4. Gap analysis — vision vs. current code + +Against the full product vision in the PRD and subsystem INTENTs, significant pieces +remain **designed but not built**: + +| Capability | PRD / INTENT status | Code status | +|------------|---------------------|-------------| +| **PDF review & evidence capture** | Primary MVP | **Implemented** | +| **Evidence-backed forms + visual guide** | Primary MVP | **Implemented** | +| **Citation card export** | Primary MVP | **Implemented** | +| **Session portability (ZIP)** | Demo enhancement | **Implemented** (CE-WP-0005) | +| **Markdown / HTML documents** | Primary goal (FR) | **Not started** — `src/source/` is PDF-only | +| **Citation recovery mode** | Third product mode | **Not started** — `CitationRecoveryAttempt` in contracts/ids only | +| **Document review status workflow** | `citation-work` INTENT | **Not wired** — `reviewStatus` enum in contracts, no UI usage | +| **External source discovery** | Future / privacy-sensitive | **Deferred** (correct per PRD non-goals) | +| **Sister repo extraction** | Post-MVP | **Not started** — all code still in umbrella | +| **Monorepo vs. polyrepo decision** | ADR-0002 | **Still blank** — blocks clean extraction | + +**Housekeeping debt:** `workplans/README.md` is stale (still lists CE-WP-0001..0004 as +`todo`); the individual workplan files correctly show `done`. + +--- + +## 5. Per-repo assessment + +### 5.1 citation-evidence — healthy, past MVP baseline + +**Strengths:** Working reference app, enforced architecture, rich documentation, completed +Ralph workplans, contracts that sister repos can defer to. + +**Risks:** Umbrella carries all complexity; extraction strategy undecided; PDF-only +implementation may hide format-neutral claims until HTML/Markdown adapters land; citation +recovery is a large remaining vertical with no code yet. + +**Verdict:** The **center of gravity** of the family. This is where all meaningful +engineering lives today. + +### 5.2 Sister repos (engine, anchor, source, work, binder) — scaffolded placeholders + +**Strengths:** Excellent `INTENT.md` + `README.md` that correctly point upstream; LICENSE +and git remotes in place; boundaries pre-negotiated via umbrella wiki. + +**Gaps:** No `package.json`, no source, no CI, no published packages. They are **boundary +documents**, not runnable libraries. + +**Verdict:** Ready as **extraction targets**, not as independent products. Extraction should +follow ADR-0002 resolution and a deliberate `git mv` + package cut per README. + +--- + +## 6. Strategic read + +The family is in a **deliberate transitional architecture**: + +```text +Phase A (complete): Design six-repo boundaries + build MVP in umbrella +Phase B (current): Harden PDF path, demo UX, contracts via real use +Phase C (next): Format expansion (MD/HTML) and/or citation recovery +Phase D (later): Extract subsystems to sister repos +``` + +Compared to the original phased plan in `history/2026-05-24-initial-assessment.md`, the +project has **skipped ahead**: Phase 1 (PDF vertical slice) and Phase 2 (form binding) +are done, plus demo/session portability. Phase 3 (format expansion) and Phase 4 (local +citation recovery) have **not** started. + +The INTENT documents describe a mature, agent-friendly architecture. The code validates the +**hardest integration path** (PDF selection → durable selectors → form binding → visual +guide → export). What remains is mostly **breadth** (more formats, recovery mode) and +**structural** (extraction, packaging). + +--- + +## 7. Recommended priorities + +1. **Update `workplans/README.md`** to reflect CE-WP-0001..0005 as done; add CE-WP-0006 + for the next vertical (Markdown adapter or local citation recovery — pick one). +2. **Resolve ADR-0002** before any extraction — monorepo workspaces vs. published + packages affects everything downstream. +3. **Either** expand formats (validates "format-neutral" claim) **or** build citation + recovery (validates third product mode) — doing both in parallel would split focus. +4. **Extract `citation-engine` first** when ready — it is the leaf node every other repo + depends on; `shared/` + `engine/` are the most stable slices. + +--- + +## 8. Bottom line + +The citation family is **well-architected on paper and materially implemented in one +place**. The six `INTENT.md` files form a consistent, boundary-aware design; the umbrella +repo has delivered a working PDF-centric MVP with tests and enforced dependency rules. The +five sister repos are **correctly empty** during umbrella-first MVP — they are extraction +targets, not lagging implementations. + +**Overall state:** design maturity high, implementation maturity solid for PDF MVP, +extraction maturity low, product breadth ~half of full PRD vision. + +The main open question is what comes next — format expansion, citation recovery, or +subsystem extraction. \ No newline at end of file diff --git a/src/anchor/pdf-viewer-adapter-spike.tsx b/src/anchor/pdf-viewer-adapter-spike.tsx index 38cbc61..28a5464 100644 --- a/src/anchor/pdf-viewer-adapter-spike.tsx +++ b/src/anchor/pdf-viewer-adapter-spike.tsx @@ -189,6 +189,11 @@ export interface PdfSpikeViewerProps { onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; /** Annotation id to scroll to and highlight on mount, if any. */ readonly scrollToAnnotationId?: string; + /** + * Bumps when the same annotation should be re-scrolled (e.g. repeat click). + * Format is opaque — typically `${annotationId}:${version}`. + */ + readonly scrollRequestKey?: string; /** * Annotation id currently focused. The matching highlight gets a * thicker border (see highlight-styles.css). `null`/undefined means @@ -217,6 +222,35 @@ export interface PdfSpikeViewerProps { readonly hideXfaLayer?: boolean; } +/** + * Nudge the PDF scroll container so `highlight` sits vertically centred. + * Best-effort: depends on highlight layer DOM being present after scroll. + */ +function centerHighlightInViewer( + utils: PdfHighlighterUtils, + highlight: Highlight, + attempt = 0, +): void { + const viewer = utils.getViewer(); + const container = viewer?.container as HTMLElement | undefined; + if (!container) return; + const rect = getHighlightClientRects(highlight.id); + if (!rect) { + if (attempt < 12) { + requestAnimationFrame(() => + centerHighlightInViewer(utils, highlight, attempt + 1), + ); + } + return; + } + const cRect = container.getBoundingClientRect(); + const highlightCenterY = rect.top + rect.height / 2; + const containerCenterY = cRect.top + cRect.height / 2; + const delta = highlightCenterY - containerCenterY; + if (Math.abs(delta) < 4) return; + container.scrollTop += delta; +} + export interface StoredAnnotation { readonly id: string; readonly text: string; @@ -235,6 +269,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) { storedAnnotations, onSelectionCaptured, scrollToAnnotationId, + scrollRequestKey, activeAnnotationId, onHighlightClicked, debugTextLayer, @@ -257,7 +292,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) { .filter((c): c is string => c !== null) .join(" "); const utilsRef = useRef(null); - const [didScroll, setDidScroll] = useState(null); + const lastScrollKeyRef = useRef(null); const highlights = useMemo(() => { const out: Highlight[] = []; @@ -283,22 +318,33 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) { return out; }, [storedAnnotations, debugTextLayer]); + const highlightsRef = useRef(highlights); + highlightsRef.current = highlights; + useEffect(() => { - if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return; + const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null; + if (!requestKey || !scrollToAnnotationId) return; + if (lastScrollKeyRef.current === requestKey) return; const utils = utilsRef.current; - const target = highlights.find((h) => h.id === scrollToAnnotationId); + const target = highlightsRef.current.find((h) => h.id === scrollToAnnotationId); if (debugTextLayer) { console.log("[ce] scrollToAnnotation requested", { id: scrollToAnnotationId, + requestKey, utilsAvailable: !!utils, targetFound: !!target, - knownIds: highlights.map((h) => h.id), + knownIds: highlightsRef.current.map((h) => h.id), }); } if (!utils || !target) return; utils.scrollToHighlight(target); - setDidScroll(scrollToAnnotationId); - }, [scrollToAnnotationId, highlights, didScroll, debugTextLayer]); + lastScrollKeyRef.current = requestKey; + // After the library scrolls the page into view, nudge so the highlight + // centre aligns with the scroll container centre (CE-WP-0006-T02). + requestAnimationFrame(() => { + centerHighlightInViewer(utils, target); + }); + }, [scrollToAnnotationId, scrollRequestKey, debugTextLayer]); return (
[ { id: "review" as const, label: "Review" }, - { id: "forms" as const, label: "Forms" }, + { id: "forms" as const, label: "Capture" }, ], [], ); diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx index fa3702b..9ce4da5 100644 --- a/src/app/forms/FormsApp.tsx +++ b/src/app/forms/FormsApp.tsx @@ -1,33 +1,30 @@ /** - * FormsApp — the evidence-backed form mode for CE-WP-0003. + * FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007). * - * Layout: + * Layout (CE-WP-0007): * * ┌────────────┬─────────────────┬─────────────┐ - * │ Collection │ FormRenderer │ ViewerShell │ - * │ │ (left) │ (right) │ + * │ Collection │ ViewerShell │ FormPane │ * ├────────────┴─────────────────┴─────────────┤ - * │ EvidenceStrip (bottom) │ + * │ 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. + * Linking: field must have focus; clicking evidence links directly. */ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EvidenceItem } from "@shared/evidence"; -import type { AnnotationId, EvidenceItemId } from "@shared/ids"; +import type { EvidenceLink } from "@shared/evidence-link"; +import type { EvidenceItemId } from "@shared/ids"; -import { Overlay, useActiveState, useBinder } from "@binder/index"; +import { + Overlay, + useActiveState, + useBinder, + useRegisterRect, +} from "@binder/index"; +import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer"; import { CollectionList, ViewerShell, @@ -39,19 +36,54 @@ import { import { FormRenderer } from "@binder/FormRenderer"; -import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips"; import { DEMO_SCHEMA } from "./demo-schema"; import { HighlightRectBridge } from "./HighlightRectBridge"; +export type EvidenceStripFilter = "all" | "attached"; + +const STRIP_FILTER_EVENT = "citation-evidence:strip-filter"; + +function publishStripFilter(mode: EvidenceStripFilter) { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode })); +} + +function quotePreview(text: string, max = 80): string { + const t = text.trim(); + return t.length > max ? `${t.slice(0, max)}…` : t; +} + export function FormsApp() { + const [schema, setSchema] = useState(() => ({ + ...DEMO_SCHEMA, + fields: [...DEMO_SCHEMA.fields], + })); + + const fieldLabels = useMemo( + () => new Map(schema.fields.map((f) => [f.id, f.label] as const)), + [schema], + ); + + const addField = useCallback(() => { + setSchema((prev) => { + const n = prev.fields.length + 1; + const field: FormFieldSchema = { + type: "text", + id: `field_${n}`, + label: `New field ${n}`, + }; + return { ...prev, fields: [...prev.fields, field] }; + }); + }, []); + return (
- +
- + @@ -59,12 +91,6 @@ export function FormsApp() { ); } -/** - * 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(); @@ -76,88 +102,114 @@ function ScrollBridge() { return null; } -function FormPane() { +function FormPane({ + schema, + onAddField, +}: { + schema: FormSchema; + onAddField: () => void; +}) { const { document } = useActiveDocument(); const { bindings } = useBinder(); const engine = useEngine(); const linkTick = useEngineEventTick("EvidenceLinkCreated"); - const { state: activeState } = useActiveState(); + const unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); + const { state: activeState, setActiveEvidence } = useActiveState(); - const [selectedForLinking, setSelectedForLinking] = useState( - null, - ); + useEffect(() => { + return engine.bus.on("FormFieldActivated", () => { + publishStripFilter("attached"); + }); + }, [engine]); + + useEffect(() => { + const target = activeState.activeTarget; + if (!target || activeState.activeEvidenceItemId) return; + const links = bindings.listEvidenceForTarget(target); + if (links.length === 0) return; + const item = engine.evidence.get(links[0]!.evidenceItemId); + if (!item) return; + setActiveEvidence(item.id, item.annotationIds[0] ?? null); + }, [ + activeState.activeTarget, + activeState.activeEvidenceItemId, + bindings, + engine, + linkTick, + unlinkTick, + setActiveEvidence, + ]); - // Compute per-field link counts. Re-derives on link create. const linkCounts = useMemo>(() => { const out: Record = {}; - for (const field of DEMO_SCHEMA.fields) { + for (const field of schema.fields) { out[field.id] = bindings.listEvidenceForTarget({ targetType: "form-field", targetId: field.id, }).length; } void linkTick; + void unlinkTick; return out; - }, [bindings, linkTick]); + }, [schema.fields, bindings, linkTick, unlinkTick]); - // 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, + const linkHints = useMemo>(() => { + const out: Record = {}; + for (const field of schema.fields) { + const links = bindings.listEvidenceForTarget({ + targetType: "form-field", + targetId: field.id, }); - setSelectedForLinking(null); - }); - }, [engine, bindings, selectedForLinking]); + if (links.length === 0) continue; + const item = engine.evidence.get(links[0]!.evidenceItemId); + const ann = item?.annotationIds[0] + ? engine.annotations.get(item.annotationIds[0]) + : null; + const quote = ann?.quote ?? item?.commentary ?? ""; + if (quote) out[field.id] = quotePreview(quote); + } + void linkTick; + void unlinkTick; + return out; + }, [schema.fields, bindings, engine, linkTick, unlinkTick]); return (
- setSelectedForLinking(null)} - /> {document ? ( - <> - - - + + Add field + + } + /> ) : ( )} -
); } @@ -165,97 +217,114 @@ function FormPane() { function EmptyHint() { return (

- Pick a fixture from the collection list to start binding evidence. + Pick a document from the collection to start capturing evidence links.

); } -function SelectedBanner({ - selectedForLinking, - onClear, +function EvidenceStrip({ + fieldLabels, }: { - selectedForLinking: EvidenceItemId | null; - onClear: () => void; + fieldLabels: ReadonlyMap; }) { - 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 { bindings } = useBinder(); 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 unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); + const { state: activeState, setActiveEvidence, clearActiveEvidence } = + useActiveState(); - const items = useMemo(() => { + const [userFilter, setUserFilter] = useState("all"); + const [sessionFilter, setSessionFilter] = useState( + null, + ); + + const effectiveFilter = sessionFilter ?? userFilter; + + useEffect(() => { + const handler = (e: Event) => { + setSessionFilter((e as CustomEvent).detail); + }; + window.addEventListener(STRIP_FILTER_EVENT, handler); + return () => window.removeEventListener(STRIP_FILTER_EVENT, handler); + }, []); + + useEffect(() => { + if (!activeState.activeTarget) { + setSessionFilter(null); + } + }, [activeState.activeTarget]); + + const allItems = useMemo(() => { if (!document) return []; void createTick; void updateTick; void linkTick; + void unlinkTick; return engine.evidence.listByDocument(document.id); - }, [document, engine, createTick, updateTick, linkTick]); + }, [document, engine, createTick, updateTick, linkTick, unlinkTick]); - const handleStage = (id: EvidenceItemId) => { - const next = stagedId === id ? null : id; - setStagedId(next); - publishStagedForLinking(next); - }; + const items = useMemo(() => { + if (effectiveFilter !== "attached" || !activeState.activeTarget) { + return allItems; + } + const links = bindings.listEvidenceForTarget(activeState.activeTarget); + const ids = new Set(links.map((l) => l.evidenceItemId)); + const attached = allItems.filter((item) => ids.has(item.id)); + return attached.length > 0 ? attached : allItems; + }, [ + allItems, + effectiveFilter, + activeState.activeTarget, + bindings, + linkTick, + unlinkTick, + ]); + + const tryLink = useCallback( + (evidenceItemId: EvidenceItemId, fieldId: string): boolean => { + const existing = bindings + .listEvidenceForTarget({ targetType: "form-field", targetId: fieldId }) + .some((l) => l.evidenceItemId === evidenceItemId); + if (existing) return false; + bindings.linkEvidenceToTarget({ + evidenceItemId, + target: { targetType: "form-field", targetId: fieldId }, + }); + return true; + }, + [bindings], + ); + + const handleCardClick = useCallback( + (item: EvidenceItem) => { + const annId = item.annotationIds[0] ?? null; + setActiveEvidence(item.id, annId); + + const target = activeState.activeTarget; + if (target?.targetType === "form-field") { + tryLink(item.id, target.targetId); + } + }, + [activeState.activeTarget, setActiveEvidence, tryLink], + ); + + const handleUnlink = useCallback( + (link: EvidenceLink) => { + bindings.unlinkEvidence(link.id); + if ( + activeState.activeEvidenceItemId === link.evidenceItemId && + activeState.activeTarget?.targetType === link.targetType && + activeState.activeTarget?.targetId === link.targetId + ) { + clearActiveEvidence(); + } + }, + [bindings, activeState, clearActiveEvidence], + ); if (!document) return null; @@ -267,56 +336,185 @@ function EvidenceStrip() { background: "#fafafa", padding: 8, display: "flex", - gap: 8, - overflowX: "auto", + flexDirection: "column", + gap: 6, flex: "0 0 auto", minHeight: 100, fontFamily: "system-ui, sans-serif", }} > - {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 ( -
+
+ {items.length === 0 && ( +

+ {effectiveFilter === "attached" + ? "No evidence linked to the active field." + : "No evidence yet. Switch to Review mode to capture a passage."} +

+ )} + {items.map((item) => ( + handleStage(item.id)} - data-staged={isStaged ? "true" : "false"} - aria-current={isActive ? "true" : undefined} - style={{ - minWidth: 220, - maxWidth: 280, - textAlign: "left", - fontSize: 12, - padding: 8, - border: isActive - ? "2px solid #0050b3" - : isStaged - ? "2px solid #f0a000" - : "1px solid #ccc", - background: isActive ? "#e8f0ff" : isStaged ? "#fff4d6" : "white", - cursor: "pointer", - }} - > -
- “{quote.slice(0, 100)} - {quote.length > 100 ? "…" : ""}” -
- {item.commentary && ( -
{item.commentary}
- )} - - ); - })} + item={item} + isActive={activeState.activeEvidenceItemId === item.id} + links={bindings.listTargetsForEvidence(item.id)} + fieldLabels={fieldLabels} + onClick={() => handleCardClick(item)} + onUnlink={handleUnlink} + /> + ))} +
); } + +function FilterToggle({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function EvidenceStripCard({ + item, + isActive, + links, + fieldLabels, + onClick, + onUnlink, +}: { + item: EvidenceItem; + isActive: boolean; + links: readonly EvidenceLink[]; + fieldLabels: ReadonlyMap; + onClick: () => void; + onUnlink: (link: EvidenceLink) => void; +}) { + const engine = useEngine(); + const ref = useRef(null); + useRegisterRect("evidence-card", item.id, ref); + + const firstAnn = item.annotationIds[0] + ? engine.annotations.get(item.annotationIds[0]) + : null; + const quote = firstAnn?.quote ?? "(no quote)"; + + const formLinks = links.filter((l) => l.targetType === "form-field"); + + return ( +
+ {formLinks.length > 0 && ( +
+ {formLinks.map((link) => { + const label = fieldLabels.get(link.targetId) ?? link.targetId; + return ( + + ); + })} +
+ )} + +
+ ); +} \ No newline at end of file diff --git a/src/binder/FormRenderer.tsx b/src/binder/FormRenderer.tsx index 2d9364d..f898422 100644 --- a/src/binder/FormRenderer.tsx +++ b/src/binder/FormRenderer.tsx @@ -14,7 +14,7 @@ * design system can land later without changing the registry contract. */ -import { useRef, type ChangeEvent } from "react"; +import { useRef, type ChangeEvent, type ReactNode } from "react"; import type { EvidenceTarget } from "@shared/evidence-link"; @@ -49,12 +49,16 @@ export interface FormRendererProps { * label so the user can tell which fields already have evidence. */ readonly linkCounts?: Readonly>; + /** Hover preview for the evidence-count chip (first linked quote). */ + readonly linkHints?: Readonly>; + readonly headerAction?: ReactNode; } function FieldRow({ field, value, linkCount, + linkHint, isActive, onChange, onFocus, @@ -62,6 +66,7 @@ function FieldRow({ field: FormFieldSchema; value: string; linkCount: number; + linkHint?: string; isActive: boolean; onChange: (next: string) => void; onFocus: () => void; @@ -100,6 +105,7 @@ function FieldRow({ {linkCount > 0 ? ( e.preventDefault()} > -

- {schema.title} -

+
+

+ {schema.title} +

+ {headerAction} +
{schema.fields.map((field) => ( onValueChange?.(field.id, next)} onFocus={() => handleFocus(field.id)} diff --git a/src/binder/services/bindings.ts b/src/binder/services/bindings.ts index a1b904b..7ff8902 100644 --- a/src/binder/services/bindings.ts +++ b/src/binder/services/bindings.ts @@ -76,7 +76,11 @@ export function createBindingService( return stored; }, unlinkEvidence(id) { - return links.delete(id); + const removed = links.delete(id); + if (removed) { + bus.emit({ type: "EvidenceLinkRemoved", linkId: id }); + } + return removed; }, updateLink(id, input) { const existing = links.get(id); diff --git a/src/binder/state/active.ts b/src/binder/state/active.ts index 13fe068..8f2738f 100644 --- a/src/binder/state/active.ts +++ b/src/binder/state/active.ts @@ -54,6 +54,7 @@ type Action = evidenceItemId: EvidenceItemId; annotationId: AnnotationId | null; } + | { type: "clear-active-evidence" } | { type: "clear" }; function reducer(state: ActiveState, action: Action): ActiveState { @@ -78,6 +79,12 @@ function reducer(state: ActiveState, action: Action): ActiveState { activeEvidenceItemId: action.evidenceItemId, activeAnnotationId: action.annotationId, }; + case "clear-active-evidence": + return { + activeTarget: state.activeTarget, + activeEvidenceItemId: null, + activeAnnotationId: null, + }; case "clear": return EMPTY_ACTIVE_STATE; } @@ -90,6 +97,7 @@ export interface ActiveStateApi { evidenceItemId: EvidenceItemId, annotationId?: AnnotationId | null, ): void; + clearActiveEvidence(): void; clear(): void; } @@ -144,13 +152,17 @@ export function ActiveStateProvider(props: ActiveStateProviderProps) { [props.bus], ); + const clearActiveEvidence = useCallback(() => { + dispatch({ type: "clear-active-evidence" }); + }, []); + const clear = useCallback(() => { dispatch({ type: "clear" }); }, []); const value = useMemo( - () => ({ state, focusTarget, setActiveEvidence, clear }), - [state, focusTarget, setActiveEvidence, clear], + () => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }), + [state, focusTarget, setActiveEvidence, clearActiveEvidence, clear], ); return createElement(ActiveStateContext.Provider, { value }, props.children); diff --git a/src/binder/visual-guide/Overlay.tsx b/src/binder/visual-guide/Overlay.tsx index 8a5f19b..aa257bf 100644 --- a/src/binder/visual-guide/Overlay.tsx +++ b/src/binder/visual-guide/Overlay.tsx @@ -33,6 +33,14 @@ function rectCenter(rect: DOMRect): { x: number; y: number } { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } +function rectBottomCenter(rect: DOMRect): { x: number; y: number } { + return { x: rect.left + rect.width / 2, y: rect.bottom }; +} + +function rectTopCenter(rect: DOMRect): { x: number; y: number } { + return { x: rect.left + rect.width / 2, y: rect.top }; +} + /** * Build a quadratic bezier from `a` to `b` whose control point bulges * horizontally between them. The horizontal-bulge style is right for a @@ -55,8 +63,8 @@ export interface OverlayProps { } export function Overlay({ - strokeColor = "#0050b3", - strokeWidth = 2, + strokeColor = "#999", + strokeWidth = 1, className, }: OverlayProps = {}) { const { state } = useActiveState(); @@ -72,10 +80,10 @@ export function Overlay({ : null; const out: string[] = []; if (fieldRect && cardRect) { - out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect))); + out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect))); } if (cardRect && highlightRect) { - out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect))); + out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect))); } void version; // memo invalidator return out; diff --git a/src/engine/events/types.ts b/src/engine/events/types.ts index e255b13..dd4a4d2 100644 --- a/src/engine/events/types.ts +++ b/src/engine/events/types.ts @@ -99,6 +99,11 @@ export interface EvidenceLinkUpdatedEvent { readonly link: EvidenceLink; } +export interface EvidenceLinkRemovedEvent { + readonly type: "EvidenceLinkRemoved"; + readonly linkId: EvidenceLinkId; +} + export interface FormFieldActivatedEvent { readonly type: "FormFieldActivated"; readonly target: EvidenceTarget; @@ -142,6 +147,7 @@ export type EngineEvent = | EvidenceItemActivatedEvent | EvidenceLinkCreatedEvent | EvidenceLinkUpdatedEvent + | EvidenceLinkRemovedEvent | FormFieldActivatedEvent | SessionCreatedEvent | SessionRenamedEvent diff --git a/src/engine/index.ts b/src/engine/index.ts index 7f6d6e5..9240954 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -35,6 +35,7 @@ export { documentIdsIn, restoreFromStorage, restoreSnapshot, + sanitizeDocumentForPersistence, type EngineSnapshot, type PersisterOptions, } from "./persistence"; diff --git a/src/engine/persistence.test.ts b/src/engine/persistence.test.ts index 8ce7f4a..874ef83 100644 --- a/src/engine/persistence.test.ts +++ b/src/engine/persistence.test.ts @@ -7,6 +7,7 @@ import { createEngine, restoreFromStorage, restoreSnapshot, + sanitizeDocumentForPersistence, type Engine, type EngineEvent, type EngineSnapshot, @@ -170,6 +171,31 @@ describe("restoreFromStorage", () => { expect(dst.documents.list()).toHaveLength(1); }); + it("strips blob: URIs from persisted documents", () => { + const engine = createEngine(); + const docId = "doc_blob" as DocumentId; + engine.documents.register({ + document: { + id: docId, + mediaType: "application/pdf", + title: "upload.pdf", + uri: "blob:http://localhost/dead", + createdAt: "2026-06-07T00:00:00.000Z", + updatedAt: "2026-06-07T00:00:00.000Z", + }, + representation: fakeDocAndRep("blob").representation, + }); + const snap = captureSnapshot(engine); + expect(snap.documents[0]?.uri).toBeUndefined(); + expect(sanitizeDocumentForPersistence({ + id: docId, + mediaType: "application/pdf", + uri: "blob:x", + createdAt: "x", + updatedAt: "x", + }).uri).toBeUndefined(); + }); + it("ignores malformed JSON without throwing", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const storage = memoryStorage(); diff --git a/src/engine/persistence.ts b/src/engine/persistence.ts index 1de5c8a..1dcff98 100644 --- a/src/engine/persistence.ts +++ b/src/engine/persistence.ts @@ -29,8 +29,15 @@ export interface EngineSnapshot { readonly evidenceItems: readonly EvidenceItem[]; } +/** Strip ephemeral blob URLs — they cannot survive reload without bytes. */ +export function sanitizeDocumentForPersistence(document: Document): Document { + if (!document.uri || !document.uri.startsWith("blob:")) return document; + const { uri: _uri, ...rest } = document; + return rest; +} + export function captureSnapshot(engine: Engine): EngineSnapshot { - const documents = engine.documents.list(); + const documents = engine.documents.list().map(sanitizeDocumentForPersistence); // Gather representations per known document. const representations: DocumentRepresentation[] = []; const annotations: Annotation[] = []; diff --git a/src/source/index.ts b/src/source/index.ts index 7b9e0e5..eedecd8 100644 --- a/src/source/index.ts +++ b/src/source/index.ts @@ -16,3 +16,8 @@ export { ingestPdfFromFile, type IngestPdfFromFileOptions, } from "./pdf/upload"; +export { + isEphemeralBlobUri, + resolvePdfViewerUrl, + documentHasUploadedBytes, +} from "./pdf/viewer-url"; diff --git a/src/source/pdf/ingest.test.ts b/src/source/pdf/ingest.test.ts index 0ffbc59..7b93c65 100644 --- a/src/source/pdf/ingest.test.ts +++ b/src/source/pdf/ingest.test.ts @@ -49,7 +49,7 @@ beforeAll(async () => { ); }); -describe("ingestPdf — fixture corpus", () => { +describe("ingestPdf — fixture corpus", { timeout: 30_000 }, () => { for (const fixture of FIXTURES) { describe(fixture.id, () => { const path = resolve(FIXTURE_DIR, fixture.filename); diff --git a/src/source/pdf/viewer-url.test.ts b/src/source/pdf/viewer-url.test.ts new file mode 100644 index 0000000..0168d3e --- /dev/null +++ b/src/source/pdf/viewer-url.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import type { Document } from "@shared/document"; +import type { DocumentId } from "@shared/ids"; + +import { createPdfByteStore } from "./byte-store"; +import { isEphemeralBlobUri, resolvePdfViewerUrl } from "./viewer-url"; + +function doc(overrides: Partial = {}): Document { + return { + id: "doc_1" as DocumentId, + mediaType: "application/pdf", + title: "sample.pdf", + createdAt: "2026-06-07T00:00:00.000Z", + updatedAt: "2026-06-07T00:00:00.000Z", + ...overrides, + }; +} + +describe("resolvePdfViewerUrl", () => { + it("prefers live byte-store blob over persisted document.uri", () => { + const store = createPdfByteStore({ + createObjectURL: () => "blob:live", + revokeObjectURL: () => {}, + }); + store.put("doc_1" as DocumentId, new Uint8Array([1])); + const url = resolvePdfViewerUrl( + doc({ uri: "blob:stale-from-localStorage" }), + store, + ); + expect(url).toBe("blob:live"); + }); + + it("uses non-blob document.uri when byte store is empty", () => { + const store = createPdfByteStore(); + expect(resolvePdfViewerUrl(doc({ uri: "file:///x.pdf" }), store)).toBe( + "file:///x.pdf", + ); + }); + + it("falls back to fixture path when only a stale blob uri is stored", () => { + const store = createPdfByteStore(); + expect( + resolvePdfViewerUrl(doc({ uri: "blob:revoked", title: "a.pdf" }), store), + ).toBe("/fixtures/pdfs/a.pdf"); + }); +}); + +describe("isEphemeralBlobUri", () => { + it("detects blob URLs", () => { + expect(isEphemeralBlobUri("blob:http://localhost/x")).toBe(true); + expect(isEphemeralBlobUri("/fixtures/pdfs/x.pdf")).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/source/pdf/viewer-url.ts b/src/source/pdf/viewer-url.ts new file mode 100644 index 0000000..a4d5d4c --- /dev/null +++ b/src/source/pdf/viewer-url.ts @@ -0,0 +1,43 @@ +/** + * Resolve the URL the PDF viewer should load for a document. + * + * Uploaded PDFs are served via ephemeral `blob:` URLs owned by + * `PdfByteStore`. Those URLs must not be persisted (see persistence + * sanitization) and must be re-resolved from live bytes on every render. + */ + +import type { Document } from "@shared/document"; +import type { DocumentId } from "@shared/ids"; + +import type { PdfByteStore } from "./byte-store"; + +export function isEphemeralBlobUri(uri: string | undefined): boolean { + return typeof uri === "string" && uri.startsWith("blob:"); +} + +/** + * Prefer the byte store's live blob URL for uploaded documents; fall back + * to a stable HTTP fixture path or a non-blob `document.uri`. + */ +export function resolvePdfViewerUrl( + document: Document, + byteStore: PdfByteStore, +): string | null { + const live = byteStore.get(document.id); + if (live) return live.blobUrl; + + if (document.uri && !isEphemeralBlobUri(document.uri)) { + return document.uri; + } + + const titleOrId = document.title ?? document.id; + return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`; +} + +/** True when bytes exist in the store but the document record lacks a URI. */ +export function documentHasUploadedBytes( + documentId: DocumentId, + byteStore: PdfByteStore, +): boolean { + return byteStore.has(documentId); +} \ No newline at end of file diff --git a/src/work/ViewerShell.tsx b/src/work/ViewerShell.tsx index 9946e02..cb5ca7b 100644 --- a/src/work/ViewerShell.tsx +++ b/src/work/ViewerShell.tsx @@ -15,6 +15,7 @@ import { useCallback, useMemo } from "react"; import { PdfSpikeViewer, type StoredAnnotation } from "@anchor/index"; +import { resolvePdfViewerUrl } from "@source/pdf/viewer-url"; import type { AnnotationId } from "@shared/ids"; import { useActiveDocument, @@ -22,12 +23,14 @@ import { useEngineEventTick, useLastActivatedEvidence, usePendingSelection, + usePdfByteStore, useScrollToAnnotation, } from "./EngineContext"; import { useDebugFlag } from "./useDebugFlags"; export function ViewerShell() { const engine = useEngine(); + const byteStore = usePdfByteStore(); const { document, representation } = useActiveDocument(); const { set: setPending } = usePendingSelection(); const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation(); @@ -62,10 +65,11 @@ export function ViewerShell() { const fileUrl = useMemo(() => { if (!document) return null; - if (document.uri) return document.uri; - const titleOrId = document.title ?? document.id; - return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`; - }, [document]); + return resolvePdfViewerUrl(document, byteStore); + }, [document, byteStore]); + + const scrollRequestKey = + scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null; const handleHighlightClicked = useCallback( (annotationId: string) => { @@ -112,13 +116,10 @@ export function ViewerShell() { >
{ ); }); -describe("create + resolve round-trip — fixture corpus", () => { +describe("create + resolve round-trip — fixture corpus", { timeout: 30_000 }, () => { for (const fixture of FIXTURES) { it(`${fixture.id}: known-good quote round-trips with confidence ≥ 0.9`, async () => { const bytes = new Uint8Array(readFileSync(resolve(FIXTURE_DIR, fixture.filename))); diff --git a/tests/integration/app-prd-scenario.dom.test.tsx b/tests/integration/app-prd-scenario.dom.test.tsx index 8d76a2e..4f6c8c4 100644 --- a/tests/integration/app-prd-scenario.dom.test.tsx +++ b/tests/integration/app-prd-scenario.dom.test.tsx @@ -168,14 +168,16 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => { ); }); - // The toolbar should appear with the quoted text preview. - const toolbar = await screen.findByText(/New annotation/); - expect(toolbar).toBeTruthy(); + // The inline capture form should appear with the quoted text preview. + await screen.findByTestId("inline-capture-form"); + await screen.findByText(/New evidence/); // Step 4: add a comment and save. - const textarea = screen.getByPlaceholderText(/Add a one-line comment/); - await user.type(textarea, "Important deadline clause"); - await user.click(screen.getByRole("button", { name: /Save evidence/ })); + await user.type( + screen.getByTestId("inline-capture-commentary"), + "Important deadline clause", + ); + await user.click(screen.getByTestId("inline-capture-save")); // Step 5: the item appears in the sidebar. The commentary text is // unique to the right pane (the collection list never echoes it back). diff --git a/tests/integration/forms-active-cycling.dom.test.tsx b/tests/integration/forms-active-cycling.dom.test.tsx index 37ccb12..aa6fdd6 100644 --- a/tests/integration/forms-active-cycling.dom.test.tsx +++ b/tests/integration/forms-active-cycling.dom.test.tsx @@ -128,25 +128,25 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => { [{ 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 user.type( + screen.getByTestId("inline-capture-commentary"), + "Form-cycling test evidence", + ); + await user.click(screen.getByTestId("inline-capture-save")); await screen.findByText(/Form-cycling test evidence/); // --- Switch to Forms mode. - await user.click(screen.getByRole("button", { name: "Forms" })); + await user.click(screen.getByRole("button", { name: "Capture" })); // 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. + // Focus Summary, then click strip card → link gets created. const summaryField = screen.getByLabelText("Summary of the matter"); await user.click(summaryField); + await user.click(stripCard); // Link chip on Summary now shows "1 evidence" await waitFor( @@ -170,12 +170,13 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => { await user.click(screen.getByLabelText("Disputed amount")); await user.click(summaryField); - // The chip rendered inside the form pane has aria-current="true". + // The strip card for the linked evidence has aria-current="true". await waitFor(() => { - const chip = document.querySelector( - '[data-evidence-id][aria-current="true"]', + const card = document.querySelector( + 'button[aria-current="true"]', ); - expect(chip).not.toBeNull(); + expect(card).not.toBeNull(); + expect(card!.textContent).toMatch(/Form-cycling test evidence/); }); // The viewer was asked to scroll to the underlying annotation. diff --git a/tests/integration/forms-link-flow.dom.test.tsx b/tests/integration/forms-link-flow.dom.test.tsx index 9921713..a61a643 100644 --- a/tests/integration/forms-link-flow.dom.test.tsx +++ b/tests/integration/forms-link-flow.dom.test.tsx @@ -1,19 +1,9 @@ /** - * CE-WP-0003-T05 integration — the side-by-side Forms layout + - * click-evidence-then-click-field linking interaction. + * CE-WP-0003-T05 / CE-WP-0006-T05 — bidirectional evidence ↔ field linking. * - * Mirrors `app-prd-scenario.dom.test.tsx` (T09 of CE-WP-0002): - * - mocks `@anchor/index` to swap PdfSpikeViewer for an inert div - * - mocks `@source/index.ingestPdf` to skip PDF.js - * - * The flow: - * 1. Render , switch to Forms mode via the top-bar button. - * 2. Open the fixture (CollectionList click). - * 3. Seed an EvidenceItem directly via the engine (creating one through - * the UI requires Review mode and is exercised by T09). - * 4. Click the evidence card in the strip → staged for linking. - * 5. Click a form field → BindingService.linkEvidenceToTarget called. - * 6. The field's link-count chip shows "1 evidence". + * Flow: + * 1. Review mode: seed session, capture selection, save evidence. + * 2. Capture mode: field-focus-gated direct link (focus field, click card). */ // @vitest-environment happy-dom @@ -22,7 +12,6 @@ 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 { AnnotationId } from "@shared/ids"; import type { Selector } from "@shared/selector"; import type { PdfSelectionCapture } from "@anchor/index"; @@ -35,37 +24,65 @@ interface ViewerProps { onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; } +interface ViewerSnapshot { + onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null; +} + +const viewerSnapshot: ViewerSnapshot = { onSelectionCaptured: null }; + vi.mock("@anchor/index", async (importOriginal) => { const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { - return ( -
- ); - }; - return { - ...original, - PdfSpikeViewer: MockPdfSpikeViewer, + viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; + return
; }; + return { ...original, PdfSpikeViewer: MockPdfSpikeViewer }; }); const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!; +const SYNTHETIC_CANONICAL = [ + "Pre.", + FIXTURE.known_good_quote, + "Post.", +].join(" "); -// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed. import { seedSessionWithDoc } from "./helpers/seed-session"; +function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture { + return { + kind: "pdf", + text, + page, + rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }], + boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 }, + }; +} + async function loadApp() { const { App } = await import("@app/App"); return render(); } -describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => { +async function saveEvidenceInReview( + user: ReturnType, + commentary: string, +) { + 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.getByTestId("inline-capture-commentary"), commentary); + await user.click(screen.getByTestId("inline-capture-save")); + await screen.findByText(new RegExp(commentary)); +} + +describe("FormsApp — focus-gated linking (CE-WP-0007-T02)", () => { beforeEach(() => { + viewerSnapshot.onSelectionCaptured = null; globalThis.localStorage?.clear(); - // Forms mode is hash-driven; make sure we start clean. if (typeof window !== "undefined") { history.replaceState(null, "", window.location.pathname); } @@ -76,74 +93,64 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)" cleanup(); }); - it("stages an evidence item then links it to the clicked field", async () => { - seedSessionWithDoc({ - sessionName: "T05-link", - documentTitle: FIXTURE.filename, - canonicalText: "Synthetic canonical text for the form-link test.", - }); - const user = userEvent.setup(); - await loadApp(); + it( + "links when field has focus: focus field then click strip card", + { timeout: 15000 }, + async () => { + seedSessionWithDoc({ + sessionName: "T05-field-first", + documentTitle: FIXTURE.filename, + canonicalText: SYNTHETIC_CANONICAL, + }); + const user = userEvent.setup(); + await loadApp(); + await saveEvidenceInReview(user, "Field-first link test"); - // Switch to Forms via the top-bar button. - await user.click(screen.getByRole("button", { name: "Forms" })); + await user.click(screen.getByRole("button", { name: "Capture" })); + await screen.findByRole("button", { name: /Field-first link test/ }); - // CE-WP-0005: doc is pre-seeded into the active session. - // Wait for the form to appear. - await waitFor(() => { - expect(screen.queryByLabelText("Summary of the matter")).not.toBeNull(); - }); + await user.click(screen.getByLabelText("Disputed amount")); + const stripCard = screen.getByRole("button", { + name: /Field-first link test/, + }); + await user.click(stripCard); - // 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, - }), - ); - }); + await waitFor(() => { + expect(screen.getByTestId("field-amount-chip").textContent).toMatch( + /1 evidence/, + ); + }); + }, + ); - // Click the Summary field → triggers FormFieldActivated → BindingService - // creates the link. - const summaryField = screen.getByLabelText("Summary of the matter"); - await user.click(summaryField); + it( + "does not link when no field is focused", + { timeout: 15000 }, + async () => { + seedSessionWithDoc({ + sessionName: "T07-no-focus", + documentTitle: FIXTURE.filename, + canonicalText: SYNTHETIC_CANONICAL, + }); + const user = userEvent.setup(); + await loadApp(); + await saveEvidenceInReview(user, "No-focus link test"); - // 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/); - }); + await user.click(screen.getByRole("button", { name: "Capture" })); + const stripCard = await screen.findByRole("button", { + name: /No-focus link test/, + }); + await user.click(stripCard); + + expect(screen.queryByTestId("field-summary-chip")).toBeNull(); + expect(screen.queryByTestId("field-amount-chip")).toBeNull(); + }, + ); it("starts in the empty state when no session is active (CE-WP-0005 default)", async () => { - await loadApp(); - // The empty-state landing is what users see now until they create - // a session. - expect(screen.getByTestId("empty-state")).toBeTruthy(); - // No demo form rendered yet. + const { unmount } = await loadApp(); + expect(screen.getAllByTestId("empty-state").length).toBeGreaterThanOrEqual(1); expect(screen.queryByText("Demo evidence-backed form")).toBeNull(); + unmount(); }); -}); - -// Silence unused-import warnings for type-only imports referenced via JSX. -void ((): AnnotationId | null => null); +}); \ No newline at end of file diff --git a/tests/integration/forms-overlay-e2e.dom.test.tsx b/tests/integration/forms-overlay-e2e.dom.test.tsx index d15dabf..50913c9 100644 --- a/tests/integration/forms-overlay-e2e.dom.test.tsx +++ b/tests/integration/forms-overlay-e2e.dom.test.tsx @@ -136,25 +136,24 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => { ); }); await user.type( - screen.getByPlaceholderText(/Add a one-line comment/), + screen.getByTestId("inline-capture-commentary"), "Overlay E2E evidence", ); - await user.click(screen.getByRole("button", { name: /Save evidence/ })); + await user.click(screen.getByTestId("inline-capture-save")); await screen.findByText(/Overlay E2E evidence/); // Step 5: navigate to forms via the top-bar. - await user.click(screen.getByRole("button", { name: "Forms" })); + await user.click(screen.getByRole("button", { name: "Capture" })); // CE-WP-0005: route is now session-scoped. expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/); - // Step 6: stage the evidence in the strip, then click the summary - // field to create the link. + // Step 6: focus summary, then click the strip card to link directly. + const summaryField = screen.getByLabelText("Summary of the matter"); + await user.click(summaryField); 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 @@ -170,8 +169,9 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => { ); expect(fieldRow).not.toBeNull(); }); - const activeChip = document.querySelector('[data-evidence-id][aria-current="true"]'); - expect(activeChip).not.toBeNull(); + const activeCard = document.querySelector('button[aria-current="true"]'); + expect(activeCard).not.toBeNull(); + expect(activeCard!.textContent).toMatch(/Overlay E2E evidence/); // Step 9: SVG overlay renders 2 paths (field→card + card→highlight). // HighlightRectBridge registers via the mocked getHighlightClientRects. diff --git a/tests/integration/forms-strip-filter.dom.test.tsx b/tests/integration/forms-strip-filter.dom.test.tsx new file mode 100644 index 0000000..9e58f97 --- /dev/null +++ b/tests/integration/forms-strip-filter.dom.test.tsx @@ -0,0 +1,124 @@ +/** + * CE-WP-0006-T04 — evidence strip filter (all vs attached-to-active-field). + */ + +// @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 { 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; +} + +const viewerSnapshot: { onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null } = { + onSelectionCaptured: null, +}; + +vi.mock("@anchor/index", async (importOriginal) => { + const original = await importOriginal(); + const MockPdfSpikeViewer = (props: ViewerProps) => { + viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; + return
; + }; + return { ...original, PdfSpikeViewer: MockPdfSpikeViewer }; +}); + +const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!; +const SYNTHETIC_CANONICAL = [ + "Alpha.", + FIXTURE.known_good_quote, + "Beta.", +].join(" "); + +import { seedSessionWithDoc } from "./helpers/seed-session"; + +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(); +} + +async function captureAndSave(user: ReturnType, commentary: string) { + 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.getByTestId("inline-capture-commentary"), commentary); + await user.click(screen.getByTestId("inline-capture-save")); + await screen.findByText(new RegExp(commentary)); +} + +describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => { + beforeEach(() => { + viewerSnapshot.onSelectionCaptured = null; + globalThis.localStorage?.clear(); + if (typeof window !== "undefined") { + history.replaceState(null, "", window.location.pathname); + } + seedSessionWithDoc({ + sessionName: "T04-filter", + documentTitle: FIXTURE.filename, + canonicalText: SYNTHETIC_CANONICAL, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it( + "narrows to attached evidence on field focus and restores on All toggle", + { timeout: 20000 }, + async () => { + const user = userEvent.setup(); + await loadApp(); + + await captureAndSave(user, "Linked to summary"); + await captureAndSave(user, "Unlinked orphan"); + + await user.click(screen.getByRole("button", { name: "Capture" })); + const linkedCard = await screen.findByRole("button", { + name: /Linked to summary/, + }); + expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy(); + + // Link the first item to summary; field focus keeps attached filter active. + await user.click(screen.getByLabelText("Summary of the matter")); + await user.click(linkedCard); + await waitFor(() => { + expect(screen.getByTestId("field-summary-chip").textContent).toMatch( + /1 evidence/, + ); + expect(screen.queryByRole("button", { name: /Unlinked orphan/ })).toBeNull(); + }); + + await user.click(screen.getByRole("button", { name: "All" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy(); + }); + }, + ); +}); \ No newline at end of file diff --git a/workplans/CE-WP-0006-forms-ux-refinements.md b/workplans/CE-WP-0006-forms-ux-refinements.md new file mode 100644 index 0000000..98dd73b --- /dev/null +++ b/workplans/CE-WP-0006-forms-ux-refinements.md @@ -0,0 +1,305 @@ +--- +id: CE-WP-0006 +type: workplan +title: "Forms & review UX refinements — blob stability, scroll-center, linking, visual guide" +domain: citation_evidence +repo: citation-evidence +repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6 +topic_slug: citation_evidence_mvp +topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec +status: done +owner: Bernd +created: 2026-06-07 +updated: 2026-06-07 +depends_on_workplan: CE-WP-0005 +planning_order: 6 +planning_priority: high +spec_refs: + - wiki/ProductRequirementsDocument.md + - wiki/ArchitectureOverview.md + - wiki/SharedContracts.md + - history/2026-06-07-ecosystem-state-assessment.md +state_hub_workstream_id: "97493fdb-a793-4e79-bc7c-6a56b6085873" +--- + +# CE-WP-0006 — Forms & Review UX Refinements + +User-facing polish before architectural expansion (Markdown/HTML, citation +recovery, subsystem extraction). Addresses six issues observed during manual +demo use on `http://localhost:5173/`. + +## User requirements (locked) + +1. **PDF blob load failure on evidence capture** — saving a new evidence item + with commentary triggers + `Unexpected server response (0) while retrieving PDF "blob:http://…"`. +2. **Center cited region on evidence select** — clicking an evidence item must + scroll the document viewport so the marked passage is centred, not merely + brought into view at an edge. +3. **Visual guide from footer strip only** — in Forms mode, connection lines + must originate from the bottom evidence strip cards, not duplicate cards + below the form. The field end of each line should attach to the **bottom + edge of the form pane** to reduce overlap with field inputs. +4. **Evidence strip visibility filter** — the bottom row supports an optional + filter (`all` | `attached`). Focusing a form field switches the filter to + `attached` (evidence linked to that field only). +5. **Bidirectional linking** — linking must work both ways: + - evidence → field (existing staged flow), and + - field → evidence (select/focus field, then click evidence card to link). +6. **Link indicators on evidence cards** — connected cards show one or more link + symbols in the upper-right. Hover shows a tooltip listing connected form + field titles. Clicking a symbol removes that specific link. + +## Scoping decisions + +- **No architecture extraction** in this workplan — all changes stay in the + umbrella repo under existing `src/{work,binder,app,anchor,source}/` folders. +- **Review mode and Forms mode** both benefit from T01 (blob stability) and T02 + (scroll centre); T03–T06 are Forms-mode focused. +- **ActiveEvidenceChips** (`src/app/forms/ActiveEvidenceChips.tsx`) is removed + from the form pane layout; its rect-registration responsibility moves to the + footer `EvidenceStrip`. The module may remain as a thin helper or be inlined + — but it must not render duplicate cards below the form. +- **Unlink** uses `bindings.unlinkEvidence(linkId)` (hard delete per current + MVP semantics in `src/binder/services/bindings.ts`). + +## Dependency order + +``` +T01 (blob URL stability) + └─ T02 (viewport centre on evidence select) +T03 (strip-only visual guide + remove duplicate chips) + ├─ T04 (strip filter: all | attached) + ├─ T05 (bidirectional linking) + └─ T06 (link badges + tooltip + unlink) + └─ T07 (integration tests) +T08 (update workplans/README.md) — parallel once file exists +``` + +--- + +## T01 — Fix PDF blob URL failure during evidence capture + +```task +id: CE-WP-0006-T01 +priority: critical +status: done +state_hub_task_id: "3cc6a93a-e506-4477-8ef7-8c6dee405bc8" +``` + +**Problem:** uploaded PDFs are served via ephemeral `blob:` URLs minted by +`PdfByteStore` and stamped onto `document.uri`. A full viewer remount (e.g. +`ViewerShell` re-keyed on `scrollVersion`) or persistence round-trip can leave +PDF.js fetching a revoked or stale blob URL. + +**Investigate and fix:** + +- `ViewerShell` should resolve the viewer URL from the live `PdfByteStore` + entry for `document.id` when bytes are present; treat persisted `document.uri` + blob URLs as hints only, never as the sole source after reload. +- Avoid unnecessary full `PdfSpikeViewer` remounts when only scroll-target + changes (split scroll trigger from component `key` if that is the root cause). +- Ensure `captureSnapshot` / restore does not persist blob URLs that cannot be + rehydrated, or re-mint blob URLs from stored bytes on session/engine restore + when ZIP import or upload repopulates the byte store. +- Add a regression test reproducing capture-after-upload without blob error + (`src/work/` or `tests/integration/`). + +**Acceptance:** upload a PDF, select text, add commentary, save evidence — no +PDF.js blob fetch error; viewer remains usable. + +--- + +## T02 — Centre viewport on marked region when selecting evidence + +```task +id: CE-WP-0006-T02 +priority: high +status: done +depends_on: [T01] +state_hub_task_id: "e09d8fa1-862e-4c83-b1c4-19c6826e4610" +``` + +**Problem:** `scrollToHighlight` brings the passage into view but does not +centre it. Users lose context when the highlight lands at the viewport edge. + +**Implement:** + +- Extend the viewer adapter scroll contract (`PdfSpikeViewer` / + `react-pdf-highlighter-plus` utils) so scroll-to-annotation centres the + highlight rect vertically (and horizontally when wider than viewport). +- Wire through `useScrollToAnnotation` callers: + `EvidenceSidebar`, `ViewerShell` highlight click, `ScrollBridge` in Forms + mode. +- Unit test for scroll math helper if extracted; DOM integration test asserting + scroll request includes centre intent (mock utils acceptable per ADR-0004). + +**Acceptance:** click any evidence item in Review or Forms — the cited passage +appears centred in the document pane. + +--- + +## T03 — Visual guide from footer evidence strip; remove duplicate form cards + +```task +id: CE-WP-0006-T03 +priority: high +status: done +depends_on: [T02] +state_hub_task_id: "f30df6ee-a1c4-4a1b-80e0-d44e54f7b9b6" +``` + +**Problem:** `FormPane` renders `ActiveEvidenceChips` below the form while +`EvidenceStrip` already shows the same cards at the bottom. The SVG overlay +(`Overlay.tsx`) draws field→card→highlight using rects registered by the +duplicate chips. + +**Implement:** + +- Remove `ActiveEvidenceChips` from `FormPane` in `FormsApp.tsx`. +- Register `kind="evidence-card"` rects from `EvidenceStrip` buttons instead + (one registration per visible card). +- Update `Overlay` path geometry: + - field end → **bottom-centre** of the active field row (or bottom of form + pane when no specific field is active), + - card end → top-centre of the strip card, + - highlight end → unchanged. +- Adjust `forms-overlay-e2e.dom.test.tsx` expectations for the new anchor + points and single card location. + +**Acceptance:** only one evidence card row (footer strip); visual guide connects +form bottom → strip card → PDF highlight with no duplicate cards under the form. + +--- + +## T04 — Evidence strip filter (all vs attached-to-active-field) + +```task +id: CE-WP-0006-T04 +priority: medium +status: done +depends_on: [T03] +state_hub_task_id: "1dafa73d-1d8c-43b6-8934-2fbf038fc3be" +``` + +**Implement in `EvidenceStrip`:** + +- Filter state: `all` (default) | `attached`. +- UI toggle in the strip header (e.g. "All" / "Linked to field"). +- When a form field receives focus (`FormFieldActivated` or + `useActiveState().activeTarget`), set filter to `attached` and show only + evidence items with an `EvidenceLink` to that target. +- When no field is active, `attached` filter shows evidence linked to any field + in the demo schema, or falls back to `all` — pick the less surprising option + and document it in the task commit message. +- Clearing field focus returns filter to user’s last explicit choice (not forced + back to `all`). + +**Acceptance:** focus "Summary" field → strip shows only evidence linked to +`summary`; toggle restores full list. + +--- + +## T05 — Bidirectional evidence ↔ field linking + +```task +id: CE-WP-0006-T05 +priority: high +status: done +depends_on: [T03] +state_hub_task_id: "2d7c9278-1ecc-4b59-ae3b-1a5d1e513885" +``` + +**Current:** evidence-first only — stage card in strip, then click field. + +**Add field-first:** + +- Click/focus a form field → enter "field staged for linking" state (banner + parallel to existing evidence-staged banner). +- Click an evidence card in the strip → `bindings.linkEvidenceToTarget` with + the staged field as target; clear staging. +- Evidence-first path remains unchanged. +- Mutual exclusion: staging evidence clears field staging and vice versa. +- Emit existing `EvidenceLinkCreated` event; link count chips on fields update. + +**Acceptance:** link works via evidence→field and field→evidence; cancel clears +staging; duplicate link to same target is prevented or noop with user feedback. + +--- + +## T06 — Link badges on evidence cards (tooltip + unlink) + +```task +id: CE-WP-0006-T06 +priority: high +status: done +depends_on: [T05] +state_hub_task_id: "064f6e48-e791-4cf5-9d04-df3fb7a11e48" +``` + +**On each evidence card in `EvidenceStrip`:** + +- Query `bindings.listTargetsForEvidence(item.id)`. +- Render one link icon per connected form field in the card’s upper-right + (stack or count badge when >3). +- `title` / `aria-label` on hover: field label from `DEMO_SCHEMA` (fallback to + `targetId`). +- Click icon → `bindings.unlinkEvidence(linkId)` for that target; refresh strip + and field link counts. +- Do not trigger link-staging when clicking the unlink control (stop + propagation). + +**Acceptance:** linked cards show indicators; tooltip names fields; click removes +link without staging a new one. + +--- + +## T07 — Integration tests for refined Forms UX + +```task +id: CE-WP-0006-T07 +priority: high +status: done +depends_on: [T04, T05, T06] +state_hub_task_id: "c50f9700-9902-4520-b8e2-6a010431b7ef" +``` + +Extend or add happy-dom integration tests: + +- `tests/integration/forms-link-flow.dom.test.tsx` — bidirectional linking. +- `tests/integration/forms-overlay-e2e.dom.test.tsx` — strip-only rects, bottom + anchor geometry (path count / data attributes). +- `tests/integration/app-prd-scenario.dom.test.tsx` — capture-after-upload no + blob error (if not covered in T01 unit test). +- New or extended test for strip `attached` filter behaviour. + +**Acceptance:** `pnpm test` green; tests document the six user requirements. + +--- + +## T08 — Update workplans README index + +```task +id: CE-WP-0006-T08 +priority: low +status: done +state_hub_task_id: "715edb39-9b97-4d7f-b89b-71a0130326e8" +``` + +Update `workplans/README.md` to list CE-WP-0001..0006 with correct statuses +(0001–0005 `done`, 0006 `active`). + +**Acceptance:** README table matches workplan frontmatter. + +--- + +## Acceptance for the workplan + +After CE-WP-0006: + +1. Evidence capture on uploaded PDFs does not break the viewer. +2. Selecting evidence centres the cited passage in the document viewport. +3. Forms visual guide uses footer strip cards only; lines attach to form bottom. +4. Evidence strip supports all/attached filter; field focus narrows to attached. +5. Linking works evidence→field and field→evidence. +6. Linked evidence cards show unlinkable field indicators with tooltips. \ No newline at end of file diff --git a/workplans/CE-WP-0007-capture-view-polish.md b/workplans/CE-WP-0007-capture-view-polish.md new file mode 100644 index 0000000..9b66102 --- /dev/null +++ b/workplans/CE-WP-0007-capture-view-polish.md @@ -0,0 +1,330 @@ +--- +id: CE-WP-0007 +type: workplan +title: "Capture view polish — scroll stability, linking UX, layout, rename" +domain: citation_evidence +repo: citation-evidence +repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6 +topic_slug: citation_evidence_mvp +topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec +status: active +owner: Bernd +created: 2026-06-07 +updated: 2026-06-08 +depends_on_workplan: CE-WP-0006 +planning_order: 7 +planning_priority: high +spec_refs: + - wiki/ProductRequirementsDocument.md + - workplans/CE-WP-0006-forms-ux-refinements.md +state_hub_workstream_id: "988ee7c6-752a-492a-9ef9-76d33af9b2d6" +--- + +# CE-WP-0007 — Capture View Polish + +Follow-on UX fixes after CE-WP-0006 manual demo use. Renames the Forms +mode tab to **Capture**, recentres the document column, and tightens linking +and visual-guide behaviour. + +## User requirements (locked) + +1. **Scroll stability** — selecting evidence further down the document must + not jump the viewport back to the beginning. +2. **Focus-gated linking** — only when a form field has focus does clicking + evidence in the strip link directly (no evidence-first staging flow). +3. **Unlink clears visualization** — removing a link removes the connection + lines immediately. +4. **Field badge tooltips** — mouseover on a field's evidence-count badge + shows the first 80 characters of linked evidence, with trailing `…` when + truncated. +5. **Softer guide lines** — connection indicator lines are grey and thinner. +6. **Centred document column** — Capture layout matches Review: collection | + **viewer (centre)** | form pane (right). +7. **Rename Forms → Capture** — top-bar tab label and user-facing copy. +8. **Add Field** — button to append new form fields to the demo schema. +9. **Field type + label on add/edit** — when adding a capture field, choose + `text` | `textarea` | `date` and set the label; existing fields expose a + ✎ edit control (same interaction pattern as evidence cards in + `EvidenceSidebar`). + +## Dependency order + +``` +T01 (scroll stability) +T02 (focus-gated linking) ─┬─ T03 (unlink clears viz) + └─ T04 (field badge tooltips) +T05 (grey guide lines) — parallel +T06 (layout swap) ── T07 (rename Capture) +T08 (Add Field) — after T06/T07 +T09 (integration tests + README) +T10 (add-field type + label) ── T11 (field edit icon) + └─ T12 (tests) +``` + +--- + +## T01 — Fix viewport jump on evidence select + +```task +id: CE-WP-0007-T01 +priority: critical +status: done +state_hub_task_id: "23f8e8d0-4ccd-48e9-958c-cc8b756dcaec" +``` + +**Problem:** clicking an evidence item far down the PDF resets scroll to the +document start before (or instead of) centring the passage. + +**Fix:** + +- Harden `centerHighlightInViewer` with retried rAF passes until highlight + DOM rects are available. +- Ensure scroll requests do not remount `PdfSpikeViewer`. +- Avoid duplicate/conflicting scroll triggers from auto-activation + card click. + +**Acceptance:** click evidence items on page 2+ — viewport stays near the +cited passage, never snaps to page 1 top. + +--- + +## T02 — Link only when a form field has focus + +```task +id: CE-WP-0007-T02 +priority: high +status: done +depends_on: [T01] +state_hub_task_id: "289bed30-0bb0-42b7-ab6f-8c218133d3df" +``` + +**Remove** evidence-first staging (click card → click field). **Keep** +field-focus-gated direct link: + +- Field receives focus → user clicks evidence card → immediate + `linkEvidenceToTarget`. +- Evidence click without focused field → activate + scroll only. + +Remove evidence-staged banner and `stagedEvidenceId` state paths. + +**Acceptance:** focus Summary → click strip card → link created. Click card +with no field focused → no link, no staging banner. + +--- + +## T03 — Unlink removes connection visualization + +```task +id: CE-WP-0007-T03 +priority: high +status: done +depends_on: [T02] +state_hub_task_id: "328e8936-454f-4092-802b-9b0030d54ce5" +``` + +On `EvidenceLinkRemoved`, if the active triple no longer has a valid link +between field and evidence, clear `activeEvidenceItemId` / `activeAnnotationId` +(or select the next remaining link on the field). + +**Acceptance:** unlink via strip badge → SVG paths disappear immediately. + +--- + +## T04 — Field evidence badge hover preview + +```task +id: CE-WP-0007-T04 +priority: medium +status: done +depends_on: [T02] +state_hub_task_id: "af6a0c73-c323-4e24-85f7-64403658ae51" +``` + +On each field's `N evidence` chip in `FormRenderer`, set `title` to the +first 80 characters of the first linked evidence quote (italic), append `…` +when longer. Multiple links: join previews or show first link only (document +choice in commit). + +**Acceptance:** hover field badge → tooltip shows truncated quote. + +--- + +## T05 — Grey, thinner visual-guide lines + +```task +id: CE-WP-0007-T05 +priority: low +status: done +state_hub_task_id: "a415f7df-f641-49d8-9c21-13eb70684c64" +``` + +Update `Overlay` defaults (or Capture-mode props): stroke `#999` (or similar +grey), width `1` px. Update `Overlay.dom.test.tsx` if it asserts colour/width. + +**Acceptance:** connection lines are visibly grey and thinner than before. + +--- + +## T06 — Capture layout: document in centre column + +```task +id: CE-WP-0007-T06 +priority: high +status: done +state_hub_task_id: "1fa3cd2c-c264-4599-9e8a-bd1d74ac1faa" +``` + +Reorder `FormsApp` columns to match `ReviewLayout`: + +``` +Collection | ViewerShell | FormPane +``` + +Evidence strip remains full-width footer. + +**Acceptance:** PDF viewer is the centre pane in Capture mode. + +--- + +## T07 — Rename Forms view to Capture + +```task +id: CE-WP-0007-T07 +priority: medium +status: done +depends_on: [T06] +state_hub_task_id: "7c326eaa-839c-45ad-bef6-aab77b324332" +``` + +- Top-bar tab: `Forms` → `Capture`. +- User-facing strings in Capture mode (banners, empty hints). +- Internal route slug may remain `forms` for deep-link compat; update tests + that query the tab by visible label. + +**Acceptance:** tab reads "Capture"; integration tests updated. + +--- + +## T08 — Add Field button + +```task +id: CE-WP-0007-T08 +priority: medium +status: done +depends_on: [T06] +state_hub_task_id: "04586602-491b-46c2-9ced-48258d11bfed" +``` + +Add **Add field** control above the form. Appends a new `text` field with +auto-generated id (`field_`) and editable label placeholder. Schema held +in React state (seeded from `DEMO_SCHEMA`); rect registry and linking use +dynamic field ids. + +**Acceptance:** click Add field → new row appears; can link evidence to it. + +--- + +## T09 — Tests and README index + +```task +id: CE-WP-0007-T09 +priority: high +status: done +depends_on: [T03, T04, T05, T07, T08] +state_hub_task_id: "823d4986-892d-467e-819e-54f0f2a363e8" +``` + +- Update integration tests for Capture label, layout, focus-gated linking. +- Add/adjust tests for unlink visualization and field badge tooltip. +- Update `workplans/README.md` with CE-WP-0007. + +**Acceptance:** `npm run test` green. + +--- + +## T10 — Add-field dialog: type selection + label + +```task +id: CE-WP-0007-T10 +priority: medium +status: todo +depends_on: [T08] +state_hub_task_id: "dab723e6-66a6-4587-8ab7-e0c5e4cb5d0a" +``` + +**Current:** **Add field** immediately appends a `text` field with a +generated label (`New field N`). + +**Implement:** + +- Click **Add field** → lightweight inline form or small modal (match + `EvidenceFormBody` / `InlineCaptureForm` styling, not a new design system). +- User picks field type: `text` | `textarea` | `date` (same union as + `FormFieldSchema`). +- User enters/edits the label before confirm. +- **Add** inserts the field; **Cancel** discards. +- Stable auto-id (`field_`); label is user-facing only. + +**Acceptance:** add a `date` field labelled "Hearing date" — row renders with +correct input type and label. + +--- + +## T11 — Field edit icon (evidence-sidebar pattern) + +```task +id: CE-WP-0007-T11 +priority: medium +status: todo +depends_on: [T10] +state_hub_task_id: "c55541c7-57e2-4f5d-a4ef-ed54c470cbd9" +``` + +Mirror `EvidenceSidebar` edit UX (`✎` button, `data-testid` hooks, +inline editor, save/cancel): + +- Each `FieldRow` in `FormRenderer` gets an upper-right **edit** control + (pencil icon, `aria-label`, stop propagation on click). +- Edit mode shows type selector + label input (reuse T10 controls). +- **Save** updates schema state in `FormsApp`; **Cancel** reverts. +- Changing type swaps the rendered input; field `id` stays stable so + existing `EvidenceLink` targets and rect registrations remain valid. +- Demo-schema seed fields are editable too. + +**Acceptance:** edit "Summary of the matter" → rename + switch to `text`; +links and visual guide still resolve by field id. + +--- + +## T12 — Tests for field add/edit UX + +```task +id: CE-WP-0007-T12 +priority: high +status: todo +depends_on: [T10, T11] +state_hub_task_id: "585b054e-eb5e-410e-90ee-84e698b13f7f" +``` + +Happy-dom integration or `FormRenderer` DOM tests: + +- Add field with chosen type + custom label. +- Edit existing field label and type via pencil icon. +- Link to an added/edited field still works (focus-gated linking). + +**Acceptance:** `npm run test` green. + +--- + +## Acceptance for the workplan + +After CE-WP-0007: + +1. Evidence select does not jump viewport to document start. +2. Linking requires focused field; evidence click links directly. +3. Unlink clears visual guide lines. +4. Field badges show 80-char quote preview on hover. +5. Guide lines are grey and thin. +6. Capture mode centres the document viewer. +7. Tab reads "Capture". +8. Users can add form fields dynamically. +9. Add-field flow picks type and label; existing fields edit via ✎ control. \ No newline at end of file diff --git a/workplans/README.md b/workplans/README.md index 9d9b79f..e7b3504 100644 --- a/workplans/README.md +++ b/workplans/README.md @@ -1,22 +1,27 @@ # MVP Workplans -These four workplans implement the **first reference scenario** from -`wiki/ProductRequirementsDocument.md` §20 — end-to-end PDF evidence -capture → form binding → citation card export — entirely inside the -`citation-evidence` repository. +MVP workplans for the citation-evidence umbrella repo. CE-WP-0001..0006 +delivered the PRD §20 reference scenario and Forms/Review UX polish. +CE-WP-0007 Capture-view polish is active (field add/edit UX in progress). | Workplan | Title | Status | |----------|----------------------------------------|--------| -| `CE-WP-0001` | Foundations — scaffold, folders, lint rules, normalize, fixtures | todo | -| `CE-WP-0002` | PDF review slice — engine types, anchor, source, viewer, sidebar | todo | -| `CE-WP-0003` | Form binding + visual guide — EvidenceLink, rect registry, overlay | todo | -| `CE-WP-0004` | Citation card export — Markdown + HTML renderers, sidebar export | todo | +| `CE-WP-0001` | Foundations — scaffold, folders, lint rules, normalize, fixtures | done | +| `CE-WP-0002` | PDF review slice — engine types, anchor, source, viewer, sidebar | done | +| `CE-WP-0003` | Form binding + visual guide — EvidenceLink, rect registry, overlay | done | +| `CE-WP-0004` | Citation card export — Markdown + HTML renderers, sidebar export | done | +| `CE-WP-0005` | Demo sessions — uploads, named sessions, ZIP export/import | done | +| `CE-WP-0006` | Forms & review UX refinements — blob fix, scroll centre, linking | done | +| `CE-WP-0007` | Capture view polish — scroll, linking, layout, rename, field UX | active | ## Order -Strictly sequential. `CE-WP-0002` depends on the folder/lint scaffolding from -`CE-WP-0001`. `CE-WP-0003` and `CE-WP-0004` depend on the engine types, -viewer adapter, and sidebar from `CE-WP-0002`. +CE-WP-0001..0004 are strictly sequential. CE-WP-0005 depends on 0004. +CE-WP-0006 depends on 0005. CE-WP-0007 depends on 0006: + +``` +/ralph-workplan workplans/CE-WP-0007-capture-view-polish.md +``` ## How to run a workplan