CE-WP-0006/0007: Capture view polish, workplans, and UX refinements

- Blob URL stability, scroll centre, strip-only visual guide
- Focus-gated linking, unlink clears overlay, field badge tooltips
- Capture layout (viewer centre), grey guide lines, Add field button
- Workplans CE-WP-0006 (done) and CE-WP-0007 (T01-T09 done, T10-T12 todo)
- Integration tests and viewer-url helpers
This commit is contained in:
2026-06-08 00:37:34 +02:00
parent d25b01f6d5
commit 2fd085b65e
26 changed files with 1767 additions and 356 deletions

View File

@@ -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.

View File

@@ -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<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
const lastScrollKeyRef = useRef<string | null>(null);
const highlights = useMemo<Highlight[]>(() => {
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 (
<div

View File

@@ -288,7 +288,7 @@ function ActiveTopBar({
const tabs = useMemo(
() => [
{ id: "review" as const, label: "Review" },
{ id: "forms" as const, label: "Forms" },
{ id: "forms" as const, label: "Capture" },
],
[],
);

View File

@@ -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<FormSchema>(() => ({
...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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<CollectionList />
<FormPane />
<ViewerShell />
<FormPane schema={schema} onAddField={addField} />
</div>
<EvidenceStrip />
<EvidenceStrip fieldLabels={fieldLabels} />
<ScrollBridge />
<HighlightRectBridge />
<Overlay />
@@ -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<EvidenceItemId | null>(
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<Record<string, number>>(() => {
const out: Record<string, number> = {};
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<readonly ActiveEvidenceChipsItem[]>(() => {
if (!activeState.activeTarget) return [];
const links = bindings.listEvidenceForTarget(activeState.activeTarget);
return links
.map((link): ActiveEvidenceChipsItem | null => {
const item = engine.evidence.get(link.evidenceItemId);
if (!item) return null;
const annotationId: AnnotationId | null = item.annotationIds[0] ?? null;
const annotation = annotationId ? engine.annotations.get(annotationId) : null;
return {
evidenceItemId: link.evidenceItemId,
annotationId,
quote: annotation?.quote ?? "(no quote)",
...(item.commentary ? { commentary: item.commentary } : {}),
};
})
.filter((c): c is ActiveEvidenceChipsItem => c !== null);
// linkTick is included so newly created links populate the chips
// without an explicit refresh.
}, [activeState.activeTarget, bindings, engine, linkTick]);
// Listen for FormFieldActivated and, if an evidence is staged, create
// the link. The state machine reduction (focus-target) happens in
// parallel via ActiveStateProvider's own handler.
useEffect(() => {
return engine.bus.on("FormFieldActivated", (event) => {
if (!selectedForLinking) return;
bindings.linkEvidenceToTarget({
evidenceItemId: selectedForLinking,
target: event.target,
const linkHints = useMemo<Record<string, string>>(() => {
const out: Record<string, string> = {};
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 (
<main
style={{
flex: "1 1 0",
flex: "0 0 320px",
minWidth: 320,
borderRight: "1px solid #ddd",
borderLeft: "1px solid #ddd",
overflow: "auto",
display: "flex",
flexDirection: "column",
}}
>
<SelectedBanner
selectedForLinking={selectedForLinking}
onClear={() => setSelectedForLinking(null)}
/>
{document ? (
<>
<FormRenderer schema={DEMO_SCHEMA} linkCounts={linkCounts} />
<ActiveEvidenceChips items={activeChipItems} />
</>
<FormRenderer
schema={schema}
linkCounts={linkCounts}
linkHints={linkHints}
headerAction={
<button
type="button"
data-testid="add-field-button"
onClick={onAddField}
style={{
fontSize: 11,
padding: "4px 10px",
border: "1px solid #888",
borderRadius: 4,
background: "white",
cursor: "pointer",
}}
>
Add field
</button>
}
/>
) : (
<EmptyHint />
)}
<SelectionContext setSelected={setSelectedForLinking} />
</main>
);
}
@@ -165,97 +217,114 @@ function FormPane() {
function EmptyHint() {
return (
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
Pick a fixture from the collection list to start binding evidence.
Pick a document from the collection to start capturing evidence links.
</p>
);
}
function SelectedBanner({
selectedForLinking,
onClear,
function EvidenceStrip({
fieldLabels,
}: {
selectedForLinking: EvidenceItemId | null;
onClear: () => void;
fieldLabels: ReadonlyMap<string, string>;
}) {
if (!selectedForLinking) return null;
return (
<div
role="status"
aria-live="polite"
style={{
padding: 8,
background: "#fff4d6",
borderBottom: "1px solid #f0c040",
fontFamily: "system-ui, sans-serif",
fontSize: 12,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span>
Evidence staged for linking. Click a form field to link it, or{" "}
</span>
<button onClick={onClear} style={{ fontSize: 12, padding: "2px 8px" }}>
cancel
</button>
</div>
);
}
/**
* Bridges the strip's "stage this evidence" callback to the FormPane's
* local state. The strip lives in a sibling DOM subtree; rather than
* lifting `selectedForLinking` all the way up to FormsApp, we publish a
* setter into a module-scoped event target.
*
* Simpler than another context for one local handshake.
*/
const STAGED_EVENT = "citation-evidence:staged-for-linking";
function SelectionContext({
setSelected,
}: {
setSelected: (id: EvidenceItemId | null) => void;
}) {
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<EvidenceItemId | null>).detail;
setSelected(detail);
};
window.addEventListener(STAGED_EVENT, handler);
return () => window.removeEventListener(STAGED_EVENT, handler);
}, [setSelected]);
return null;
}
export function publishStagedForLinking(id: EvidenceItemId | null) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id }));
}
function EvidenceStrip() {
const engine = useEngine();
const { 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<EvidenceItemId | null>(null);
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
const { state: activeState, setActiveEvidence, clearActiveEvidence } =
useActiveState();
const items = useMemo<readonly EvidenceItem[]>(() => {
const [userFilter, setUserFilter] = useState<EvidenceStripFilter>("all");
const [sessionFilter, setSessionFilter] = useState<EvidenceStripFilter | null>(
null,
);
const effectiveFilter = sessionFilter ?? userFilter;
useEffect(() => {
const handler = (e: Event) => {
setSessionFilter((e as CustomEvent<EvidenceStripFilter>).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<readonly EvidenceItem[]>(() => {
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 && (
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
No evidence yet. Switch to Review mode to capture a passage.
</p>
)}
{items.map((item) => {
const firstAnn = item.annotationIds[0]
? engine.annotations.get(item.annotationIds[0])
: null;
const quote = firstAnn?.quote ?? "(no quote)";
const isStaged = stagedId === item.id;
const isActive = activeState.activeEvidenceItemId === item.id;
return (
<button
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
<span style={{ color: "#666" }}>Show:</span>
<FilterToggle
label="All"
active={effectiveFilter === "all"}
onClick={() => {
setUserFilter("all");
setSessionFilter(null);
}}
/>
<FilterToggle
label="Linked to field"
active={effectiveFilter === "attached"}
onClick={() => {
setUserFilter("attached");
setSessionFilter(null);
}}
/>
</div>
<div style={{ display: "flex", gap: 8, overflowX: "auto" }}>
{items.length === 0 && (
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
{effectiveFilter === "attached"
? "No evidence linked to the active field."
: "No evidence yet. Switch to Review mode to capture a passage."}
</p>
)}
{items.map((item) => (
<EvidenceStripCard
key={item.id}
onClick={() => handleStage(item.id)}
data-staged={isStaged ? "true" : "false"}
aria-current={isActive ? "true" : undefined}
style={{
minWidth: 220,
maxWidth: 280,
textAlign: "left",
fontSize: 12,
padding: 8,
border: isActive
? "2px solid #0050b3"
: isStaged
? "2px solid #f0a000"
: "1px solid #ccc",
background: isActive ? "#e8f0ff" : isStaged ? "#fff4d6" : "white",
cursor: "pointer",
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 100)}
{quote.length > 100 ? "…" : ""}&rdquo;
</div>
{item.commentary && (
<div style={{ color: "#333" }}>{item.commentary}</div>
)}
</button>
);
})}
item={item}
isActive={activeState.activeEvidenceItemId === item.id}
links={bindings.listTargetsForEvidence(item.id)}
fieldLabels={fieldLabels}
onClick={() => handleCardClick(item)}
onUnlink={handleUnlink}
/>
))}
</div>
</section>
);
}
function FilterToggle({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
border: active ? "1px solid #0050b3" : "1px solid #ccc",
background: active ? "#e8f0ff" : "white",
cursor: "pointer",
}}
>
{label}
</button>
);
}
function EvidenceStripCard({
item,
isActive,
links,
fieldLabels,
onClick,
onUnlink,
}: {
item: EvidenceItem;
isActive: boolean;
links: readonly EvidenceLink[];
fieldLabels: ReadonlyMap<string, string>;
onClick: () => void;
onUnlink: (link: EvidenceLink) => void;
}) {
const engine = useEngine();
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
style={{
position: "relative",
minWidth: 220,
maxWidth: 280,
flexShrink: 0,
}}
>
{formLinks.length > 0 && (
<div
style={{
position: "absolute",
top: 4,
right: 4,
display: "flex",
gap: 2,
zIndex: 1,
}}
>
{formLinks.map((link) => {
const label = fieldLabels.get(link.targetId) ?? link.targetId;
return (
<button
key={link.id}
type="button"
title={`Linked to: ${label}. Click to remove link.`}
aria-label={`Remove link to ${label}`}
onClick={(e) => {
e.stopPropagation();
onUnlink(link);
}}
style={{
fontSize: 10,
lineHeight: 1,
padding: "2px 4px",
border: "1px solid #88a",
borderRadius: 3,
background: "#eef",
cursor: "pointer",
}}
>
</button>
);
})}
</div>
)}
<button
type="button"
onClick={onClick}
aria-current={isActive ? "true" : undefined}
style={{
width: "100%",
textAlign: "left",
fontSize: 12,
padding: 8,
border: isActive ? "2px solid #0050b3" : "1px solid #ccc",
background: isActive ? "#e8f0ff" : "white",
cursor: "pointer",
}}
>
<div
style={{
fontStyle: "italic",
marginBottom: 4,
paddingRight: formLinks.length ? 24 : 0,
}}
>
&ldquo;{quote.slice(0, 100)}
{quote.length > 100 ? "…" : ""}&rdquo;
</div>
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
</button>
</div>
);
}

View File

@@ -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<Record<string, number>>;
/** Hover preview for the evidence-count chip (first linked quote). */
readonly linkHints?: Readonly<Record<string, string>>;
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 ? (
<span
data-testid={`field-${field.id}-chip`}
title={linkHint}
style={{
marginLeft: 8,
padding: "1px 6px",
@@ -128,6 +134,8 @@ export function FormRenderer({
values,
onValueChange,
linkCounts,
linkHints,
headerAction,
}: FormRendererProps) {
const { state, focusTarget } = useActiveState();
@@ -142,15 +150,27 @@ export function FormRenderer({
style={{ padding: 12 }}
onSubmit={(e) => e.preventDefault()}
>
<h2 style={{ fontSize: 14, marginTop: 0, fontFamily: "system-ui, sans-serif" }}>
{schema.title}
</h2>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 8,
}}
>
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
{schema.title}
</h2>
{headerAction}
</div>
{schema.fields.map((field) => (
<FieldRow
key={field.id}
field={field}
value={values?.[field.id] ?? ""}
linkCount={linkCounts?.[field.id] ?? 0}
linkHint={linkHints?.[field.id]}
isActive={isFieldActive(state, field.id)}
onChange={(next) => onValueChange?.(field.id, next)}
onFocus={() => handleFocus(field.id)}

View File

@@ -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);

View File

@@ -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<ActiveStateApi>(
() => ({ 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);

View File

@@ -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;

View File

@@ -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

View File

@@ -35,6 +35,7 @@ export {
documentIdsIn,
restoreFromStorage,
restoreSnapshot,
sanitizeDocumentForPersistence,
type EngineSnapshot,
type PersisterOptions,
} from "./persistence";

View File

@@ -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();

View File

@@ -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[] = [];

View File

@@ -16,3 +16,8 @@ export {
ingestPdfFromFile,
type IngestPdfFromFileOptions,
} from "./pdf/upload";
export {
isEphemeralBlobUri,
resolvePdfViewerUrl,
documentHasUploadedBytes,
} from "./pdf/viewer-url";

View File

@@ -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);

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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);
}

View File

@@ -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() {
>
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer
// Re-key on scrollVersion + debug flags so toggling any of
// them remounts the viewer (the CSS classes are on a parent,
// but a re-render is the simplest way to make sure the layers
// get re-laid-out).
// Re-key on document + debug flags only — scroll requests must
// not remount the viewer (that re-fetches the PDF blob).
key={[
document.id,
scrollVersion,
debugTextLayer ? "d" : "n",
hideCanvas ? "hc" : "",
hideTextLayer ? "ht" : "",
@@ -128,6 +129,7 @@ export function ViewerShell() {
pdfUrl={fileUrl}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
{...(scrollRequestKey ? { scrollRequestKey } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer}

View File

@@ -42,7 +42,7 @@ beforeAll(async () => {
);
});
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)));

View File

@@ -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).

View File

@@ -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.

View File

@@ -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 <App />, switch to Forms mode via the top-bar button.
* 2. Open the fixture (CollectionList click).
* 3. Seed an EvidenceItem directly via the engine (creating one through
* the UI requires Review mode and is exercised by T09).
* 4. Click the evidence card in the strip → staged for linking.
* 5. Click a form field → BindingService.linkEvidenceToTarget called.
* 6. The field's link-count chip shows "1 evidence".
* 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<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (props: ViewerProps) => {
return (
<div
data-testid="mock-pdf-viewer"
data-pdf-url={props.pdfUrl}
data-stored-count={String(props.storedAnnotations.length)}
/>
);
};
return {
...original,
PdfSpikeViewer: MockPdfSpikeViewer,
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
return <div data-testid="mock-pdf-viewer" />;
};
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(<App />);
}
describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => {
async function saveEvidenceInReview(
user: ReturnType<typeof userEvent.setup>,
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);
});

View File

@@ -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.

View File

@@ -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<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (props: ViewerProps) => {
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
return <div data-testid="mock-pdf-viewer" />;
};
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(<App />);
}
async function captureAndSave(user: ReturnType<typeof userEvent.setup>, 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();
});
},
);
});

View File

@@ -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); T03T06 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 users 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 cards 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
(00010005 `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.

View File

@@ -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_<n>`) 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_<n>`); 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.

View File

@@ -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