generated from coulomb/repo-seed
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:
205
history/2026-06-07-ecosystem-state-assessment.md
Normal file
205
history/2026-06-07-ecosystem-state-assessment.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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 }}>
|
||||
“{quote.slice(0, 100)}
|
||||
{quote.length > 100 ? "…" : ""}”
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
“{quote.slice(0, 100)}
|
||||
{quote.length > 100 ? "…" : ""}”
|
||||
</div>
|
||||
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,7 @@ export {
|
||||
documentIdsIn,
|
||||
restoreFromStorage,
|
||||
restoreSnapshot,
|
||||
sanitizeDocumentForPersistence,
|
||||
type EngineSnapshot,
|
||||
type PersisterOptions,
|
||||
} from "./persistence";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -16,3 +16,8 @@ export {
|
||||
ingestPdfFromFile,
|
||||
type IngestPdfFromFileOptions,
|
||||
} from "./pdf/upload";
|
||||
export {
|
||||
isEphemeralBlobUri,
|
||||
resolvePdfViewerUrl,
|
||||
documentHasUploadedBytes,
|
||||
} from "./pdf/viewer-url";
|
||||
|
||||
@@ -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);
|
||||
|
||||
54
src/source/pdf/viewer-url.test.ts
Normal file
54
src/source/pdf/viewer-url.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
src/source/pdf/viewer-url.ts
Normal file
43
src/source/pdf/viewer-url.ts
Normal 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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
124
tests/integration/forms-strip-filter.dom.test.tsx
Normal file
124
tests/integration/forms-strip-filter.dom.test.tsx
Normal 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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
305
workplans/CE-WP-0006-forms-ux-refinements.md
Normal file
305
workplans/CE-WP-0006-forms-ux-refinements.md
Normal 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); 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.
|
||||
330
workplans/CE-WP-0007-capture-view-polish.md
Normal file
330
workplans/CE-WP-0007-capture-view-polish.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user