Refine evidence UX: sidebar capture form, inline edit, click highlight

Significant UX iteration:

Visual palette
- Debug text-layer overlay flips from yellow to light grey so it no
  longer collides with the evidence highlight colour.
- New highlight-styles.css matches the sidebar's #fff8d6/#e0c050
  palette so a passage marked in the document and its sidebar card
  speak the same visual language.
- Active (focused) evidence: same fill, thick #b78b1c outline on both
  the highlight and the sidebar card. Library's red --scrolledTo
  box-shadow is suppressed.

Activation model
- Click an evidence card in the sidebar → activates that item +
  scrolls the viewer to the passage + thickens the borders (existing
  behaviour, now visually clearer).
- Click a highlight in the document → activates the evidence that
  owns that annotation. New `findByAnnotationId()` on EvidenceService
  is the reverse lookup. Wired through a new `onHighlightClicked`
  prop on PdfSpikeViewer + `activeAnnotationId` prop that drives the
  data-ce-active attribute on the highlight wrapper.

Inline edit
- Each evidence card has a ✎ button that flips the card into an
  inline form with the citation (quote) and commentary fields.
- Saving calls a new `AnnotationService.updateQuote()` +
  existing `EvidenceService.updateCommentary()`. The selectors are
  untouched, so the marked passage in the document stays put — the
  inline hint says so explicitly.
- New `AnnotationUpdated` event added to the engine event vocabulary
  (SharedContracts.md §4 updated).

Capture form placement
- The yellow "New annotation" toolbar that lived above the viewer is
  gone. A new InlineCaptureForm component is now slotted into the
  sidebar between the cards that bracket the new selection in
  document flow (sorted by page + y of the first PdfRectSelector).
  If the new selection is before all existing evidence it appears at
  the top; if after all of them, at the bottom.
- The legacy AnnotationToolbar.tsx is removed; the public surface
  re-exports `InlineCaptureForm` instead.

Test updates
- tests/integration/citation-card-export-e2e.dom.test.tsx: switched
  to the seed-session helper (matches the other E2Es) since the
  fixture-button click path is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:57:48 +02:00
parent f0af8887d1
commit 430c0e124c
11 changed files with 640 additions and 247 deletions

View File

@@ -7,33 +7,37 @@
* directly. When the PDF library is swapped (or the spike is replaced),
* only the adapter module changes; this shell stays the same.
*
* T06 scope: load + render the active PDF + show stored annotations. The
* selection-capture → annotation pipeline is wired in T07; the
* click-to-reopen pipeline is wired in T08.
* The annotation toolbar lived here in earlier iterations; CE-WP-0005-iter4
* moved it into the evidence sidebar so the capture form appears in the
* sidebar's document-flow position. The viewer now only renders the PDF
* and surfaces the activate/click events.
*/
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { PdfSpikeViewer, type StoredAnnotation } from "@anchor/index";
import type { AnnotationId } from "@shared/ids";
import {
useActiveDocument,
useEngine,
useEngineEventTick,
useLastActivatedEvidence,
usePendingSelection,
useScrollToAnnotation,
} from "./EngineContext";
import { AnnotationToolbar } from "./AnnotationToolbar";
import { useDebugFlag } from "./useDebugFlags";
export function ViewerShell() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { set: setPending } = usePendingSelection();
const { id: scrollToId, version: scrollVersion } = useScrollToAnnotation();
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
const [debugTextLayer] = useDebugFlag("textLayer");
const activeEvidenceId = useLastActivatedEvidence();
// The viewer needs to re-fetch its highlight list whenever annotations
// change. The tick is included in the memo deps so the list re-resolves.
const annotationTick = useEngineEventTick("AnnotationCreated");
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
const annotations = useMemo<StoredAnnotation[]>(() => {
if (!document) return [];
@@ -42,21 +46,39 @@ export function ViewerShell() {
text: a.quote ?? "",
selectors: a.selectors,
}));
}, [document, engine, annotationTick]);
}, [document, engine, annotationTick, annotationUpdateTick]);
// The annotation id that visually represents the "active" focus —
// derived from the active evidence's first annotation.
const activeAnnotationId = useMemo<AnnotationId | null>(() => {
if (!activeEvidenceId) return null;
const item = engine.evidence.get(activeEvidenceId);
return item?.annotationIds[0] ?? null;
}, [activeEvidenceId, engine]);
const fileUrl = useMemo(() => {
if (!document) return null;
// CE-WP-0005: uploads + sample sessions stash a `blob:` URL on
// `document.uri` via the per-session `PdfByteStore`. Prefer that
// over the legacy fixture-path fallback so user uploads don't get
// resolved against `/fixtures/pdfs/` (which would either 404 or —
// worse — silently return the wrong file when the filename happens
// to collide with a bundled fixture).
if (document.uri) return document.uri;
const titleOrId = document.title ?? document.id;
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
}, [document]);
const handleHighlightClicked = useCallback(
(annotationId: string) => {
if (!document) return;
const item = engine.evidence.findByAnnotationId(
document.id,
annotationId as AnnotationId,
);
if (!item) return;
engine.evidence.activate(item.id, "citation-card");
// Re-trigger scroll so a click on the highlight also keeps it
// centred in the viewport.
scrollTo(annotationId as AnnotationId);
},
[document, engine, scrollTo],
);
if (!document || !representation || !fileUrl) {
return (
<main
@@ -69,7 +91,7 @@ export function ViewerShell() {
fontFamily: "system-ui, sans-serif",
}}
>
Pick a fixture on the left to begin.
Upload a PDF on the left to begin.
</main>
);
}
@@ -84,18 +106,17 @@ export function ViewerShell() {
position: "relative",
}}
>
<AnnotationToolbar />
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer
// Re-key on scrollVersion so clicking the same item twice still
// triggers the viewer's mount-time scroll effect. Also re-key on
// debugTextLayer so toggling it remounts the viewer (the CSS
// class is on a parent, but re-mounting is the simplest way to
// make sure the text layer is re-painted with the new style).
// debugTextLayer so toggling it remounts the viewer.
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
pdfUrl={fileUrl}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer}
onSelectionCaptured={(capture, selectors) => {
setPending({ capture, selectors });