Implement CE-WP-0002 T03-T09: ingest, anchor resolution, engine, UI, persistence, e2e

Completes the PDF review slice end-to-end. After this commit a user can
open a fixture, select text, save an evidence item with commentary, see
it in the sidebar, reload the page, click the item, and the viewer
scrolls to the passage.

- T03 src/source/pdf/{fingerprint,extract,ingest}.ts + 39 fixture tests
  - SHA-256 fingerprint over a fresh ArrayBuffer (TS BufferSource-safe)
  - PDF.js text extract; per-page normalize then join with "\n\n"
  - PageMap + OffsetMap (gap-free coverage); pageLength = end - start
  - Updated manifest's Betriebskosten quote to one PDF.js extracts cleanly
- T04 src/anchor/selectors/{create,resolve}.ts + 25 unit + 7 fixture tests
  - createSelectors emits the maximal redundant set (TextQuote +
    TextPosition + PdfRect + PdfPageText when available)
  - resolveSelectors implements the SharedContracts §7 ladder; confidence
    1.0 (pos+quote) → 0.7 (rect-only) → 0 (unresolved)
  - Cross-module integration test moved to tests/integration/ to honor
    the anchor↛source boundary lint rule
- T05 engine: sync event bus over the closed §4 vocabulary, Map-backed
  repos, services, createEngine() composition root, 12 tests
- T06 work + app: three-pane shell (CollectionList | ViewerShell |
  EvidenceSidebar) wired through EngineProvider; EngineContext lives in
  src/work/ to respect the work↛app boundary; SpikeApp deleted
- T07 AnnotationToolbar: pendingSelection in context; Save runs
  createSelectors → engine.annotations.create → engine.evidence.create
- T08 click-to-reopen + localStorage persistence
  - scrollToAnnotation state in context with a version counter so a
    second click on the same item re-fires the viewer scroll
  - captureSnapshot/restoreSnapshot/attachPersister/restoreFromStorage;
    restore bypasses services to avoid event-loops
  - active-document id persisted alongside the snapshot so reload lands
    on the same fixture; ADR-0005 written
  - 9 persistence tests
- T09 tests/integration/app-prd-scenario.dom.test.tsx
  - end-to-end happy-dom test of PRD scenario steps 1-8 through the real
    React tree; viewer + ingest mocked per ADR-0004's headless-Chromium
    limitation. Fixed memo-deps bug in EvidenceSidebar/ViewerShell where
    useEngineEventTick values were not included in the useMemo deps,
    leaving stale memoization across event-driven re-renders
- vitest.config.ts: happy-dom for *.dom.test.{ts,tsx} files
- noEmit added to tsconfig so tsc -b doesn't litter src/ with .js outputs

Gates: typecheck ✓ lint ✓ test 109/109 across 11 files ✓ build ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:58:11 +02:00
parent 2a7b05c190
commit d54daf2e61
45 changed files with 3655 additions and 277 deletions

40
src/app/App.tsx Normal file
View File

@@ -0,0 +1,40 @@
/**
* App — the citation-evidence MVP shell.
*
* Three-pane layout per `wiki/ArchitectureOverview.md` §12.1:
*
* ┌────────────┬──────────────────┬────────────┐
* │ Collection │ Document Viewer │ Evidence │
* │ List │ │ Sidebar │
* └────────────┴──────────────────┴────────────┘
*
* CE-WP-0002-T06 stops at "viewer shell is rendered, evidence list is
* displayed". T07 wires the selection → annotation → evidence flow; T08
* wires the sidebar-click → scroll-to-passage round-trip.
*/
import {
CollectionList,
EngineProvider,
EvidenceSidebar,
ViewerShell,
} from "@work/index";
export function App() {
return (
<EngineProvider>
<div
style={{
display: "flex",
height: "100vh",
fontFamily: "system-ui, sans-serif",
color: "#222",
}}
>
<CollectionList />
<ViewerShell />
<EvidenceSidebar />
</div>
</EngineProvider>
);
}

View File

@@ -1,233 +0,0 @@
/**
* CE-WP-0002-T02 spike host page.
*
* Lists the fixtures from `fixtures/pdfs/manifest.json`, lets the user load
* one in the spike PDF viewer, capture a selection (the viewer's
* `onSelection` fires when text is selected), persist the resulting
* selectors to `localStorage`, and on reload restore + scroll to them.
*
* Success looks like: select a quote → click "save" → reload the tab →
* the highlight is rendered on the same passage and the page is scrolled
* to it.
*/
import { useEffect, useMemo, useState } from "react";
import {
PdfSpikeViewer,
type PdfSelectionCapture,
type StoredAnnotation,
} from "@anchor/index";
import type { Selector } from "@shared/selector";
import { newId } from "@shared/ids";
import manifest from "../../fixtures/pdfs/manifest.json";
interface FixtureEntry {
id: string;
filename: string;
description: string;
page_count: number;
known_good_quote: string;
known_good_quote_page: number;
}
const FIXTURES: FixtureEntry[] = (manifest as { fixtures: FixtureEntry[] }).fixtures;
const STORAGE_KEY = "ce-wp-0002-spike-annotations-v1";
interface StoredEntry {
id: string;
fixtureId: string;
text: string;
selectors: Selector[];
createdAt: string;
}
function loadStore(): StoredEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed as StoredEntry[];
} catch {
return [];
}
}
function saveStore(entries: StoredEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
export function SpikeApp() {
const [activeFixtureId, setActiveFixtureId] = useState<string | null>(null);
const [entries, setEntries] = useState<StoredEntry[]>(() => loadStore());
const [pending, setPending] = useState<
| { capture: PdfSelectionCapture; selectors: Selector[] }
| null
>(null);
const [scrollTo, setScrollTo] = useState<string | null>(null);
useEffect(() => {
saveStore(entries);
}, [entries]);
const activeFixture = useMemo(
() => FIXTURES.find((f) => f.id === activeFixtureId) ?? null,
[activeFixtureId],
);
const annotationsForActive = useMemo<StoredAnnotation[]>(() => {
if (!activeFixtureId) return [];
return entries
.filter((e) => e.fixtureId === activeFixtureId)
.map((e) => ({ id: e.id, text: e.text, selectors: e.selectors }));
}, [activeFixtureId, entries]);
function handleSave() {
if (!pending || !activeFixtureId) return;
const entry: StoredEntry = {
id: newId("annotation"),
fixtureId: activeFixtureId,
text: pending.capture.text,
selectors: pending.selectors,
createdAt: new Date().toISOString(),
};
setEntries((prev) => [...prev, entry]);
setPending(null);
}
function handleClear() {
if (!activeFixtureId) return;
setEntries((prev) => prev.filter((e) => e.fixtureId !== activeFixtureId));
}
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
<aside
style={{
width: 320,
borderRight: "1px solid #ddd",
padding: 12,
overflow: "auto",
flex: "0 0 320px",
}}
>
<h2 style={{ marginTop: 0 }}>CE-WP-0002-T02 Spike</h2>
<p style={{ fontSize: 12, color: "#555" }}>
Pick a fixture, select text in the viewer, save, then reload the page
to verify the highlight is restored.
</p>
<h3 style={{ fontSize: 14 }}>Fixtures</h3>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{FIXTURES.map((f) => (
<li key={f.id} style={{ marginBottom: 6 }}>
<button
onClick={() => {
setActiveFixtureId(f.id);
setPending(null);
setScrollTo(null);
}}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: f.id === activeFixtureId ? "#e8f0ff" : "white",
border: "1px solid #ccc",
padding: 6,
cursor: "pointer",
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}>{f.id}</div>
<div style={{ fontSize: 11, color: "#666" }}>
{f.page_count} page{f.page_count === 1 ? "" : "s"} ·
known-good p{f.known_good_quote_page}
</div>
<div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>
&ldquo;{f.known_good_quote}&rdquo;
</div>
</button>
</li>
))}
</ul>
{activeFixture && (
<>
<h3 style={{ fontSize: 14, marginTop: 16 }}>Saved annotations</h3>
{annotationsForActive.length === 0 && (
<p style={{ fontSize: 12, color: "#888" }}>(none)</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{annotationsForActive.map((a) => (
<li key={a.id} style={{ marginBottom: 4 }}>
<button
onClick={() => setScrollTo(a.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: "#fff8d6",
border: "1px solid #ccc",
padding: 4,
cursor: "pointer",
fontSize: 11,
}}
>
{a.text.slice(0, 80)}
{a.text.length > 80 ? "…" : ""}
</button>
</li>
))}
</ul>
{annotationsForActive.length > 0 && (
<button
onClick={handleClear}
style={{ marginTop: 8, fontSize: 11 }}
>
Clear all for this fixture
</button>
)}
</>
)}
{pending && (
<div
style={{
marginTop: 16,
padding: 8,
border: "1px solid #f0c040",
background: "#fff8d6",
}}
>
<div style={{ fontSize: 12 }}>
Pending selection ({pending.selectors.length} selector
{pending.selectors.length === 1 ? "" : "s"}):
</div>
<div style={{ fontSize: 11, color: "#666", margin: "4px 0" }}>
&ldquo;{pending.capture.text.slice(0, 120)}&rdquo;
</div>
<button onClick={handleSave}>Save</button>{" "}
<button onClick={() => setPending(null)}>Discard</button>
</div>
)}
</aside>
<main style={{ flex: 1, overflow: "hidden", position: "relative" }}>
{activeFixture ? (
<PdfSpikeViewer
key={activeFixture.id}
pdfUrl={`/fixtures/pdfs/${encodeURIComponent(activeFixture.filename)}`}
storedAnnotations={annotationsForActive}
{...(scrollTo ? { scrollToAnnotationId: scrollTo } : {})}
onSelectionCaptured={(capture, selectors) =>
setPending({ capture, selectors })
}
/>
) : (
<div style={{ padding: 24, color: "#666" }}>
Pick a fixture on the left to begin.
</div>
)}
</main>
</div>
);
}

View File

@@ -1 +1 @@
export { SpikeApp } from "./SpikeApp";
export { App } from "./App";

View File

@@ -1,12 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { SpikeApp } from "./SpikeApp";
import { App } from "./App";
const container = document.getElementById("root");
if (!container) throw new Error("#root not found");
createRoot(container).render(
<StrictMode>
<SpikeApp />
<App />
</StrictMode>,
);