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

219
src/work/EngineContext.tsx Normal file
View File

@@ -0,0 +1,219 @@
/**
* Engine + active-document React context.
*
* MVP composition root for the UI: one `Engine` instance for the lifetime of
* the SPA, plus the "what's open in the viewer right now" pointer.
* `useEngine()` returns the engine; `useActiveDocument()` returns the
* currently-loaded `{document, representation}` pair, refreshed when the
* engine emits `DocumentImported` / `DocumentRepresentationGenerated`.
*
* Replaces ad-hoc engine wiring inside each component. Per the workplan
* (T07 note), state lives in a single React context; no Zustand or Redux.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { AnnotationId, DocumentId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import {
attachPersister,
createEngine,
restoreFromStorage,
type Engine,
} from "@engine/index";
import type { PdfSelectionCapture } from "@anchor/index";
/**
* localStorage keys for the engine snapshot and the UI's "what was open"
* pointer. ADR-0005 frames both as deliberately temporary — real
* persistence later.
*/
const STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
/**
* The pending selection lives in context (not local component state) because
* the toolbar that consumes it is rendered above the viewer, not inside it.
* `null` means "no selection waiting for a comment".
*/
export interface PendingSelection {
readonly capture: PdfSelectionCapture;
readonly selectors: readonly Selector[];
}
interface EngineContextValue {
readonly engine: Engine;
readonly activeDocumentId: DocumentId | null;
setActiveDocumentId(id: DocumentId | null): void;
readonly pendingSelection: PendingSelection | null;
setPendingSelection(pending: PendingSelection | null): void;
readonly scrollToAnnotationId: AnnotationId | null;
/** The version counter bumps even when the same id is set twice in a row,
* so a second click on the same evidence item still triggers a scroll. */
readonly scrollVersion: number;
scrollToAnnotation(id: AnnotationId | null): void;
}
const EngineContext = createContext<EngineContextValue | null>(null);
interface EngineProviderProps {
readonly children: ReactNode;
/** Inject a pre-built engine for tests; production uses the default. */
readonly engine?: Engine;
}
export function EngineProvider({ children, engine: injected }: EngineProviderProps) {
const engine = useMemo(() => injected ?? createEngine(), [injected]);
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
id: null,
version: 0,
});
// Restore from localStorage on first mount, then attach the persister.
// The injected-engine path skips persistence (tests own their lifecycle).
useEffect(() => {
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
const result = restoreFromStorage(engine, { key: STORAGE_KEY });
if (result.restored) {
const saved = globalThis.localStorage.getItem(ACTIVE_KEY);
if (saved && engine.documents.get(saved as DocumentId)) {
setActiveDocumentIdState(saved as DocumentId);
}
}
return attachPersister(engine, { key: STORAGE_KEY });
}, [engine, injected]);
// Persist the active-document pointer alongside the engine snapshot so a
// reload lands the user back where they were.
useEffect(() => {
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
if (activeDocumentId) {
globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId);
} else {
globalThis.localStorage.removeItem(ACTIVE_KEY);
}
}, [activeDocumentId, injected]);
// Switching the active document discards any pending selection — it
// belongs to the previous document's viewer state.
const setActiveDocumentId = useCallback((id: DocumentId | null) => {
setActiveDocumentIdState(id);
setPendingSelection(null);
setScrollState((prev) => ({ id: null, version: prev.version + 1 }));
}, []);
const scrollToAnnotation = useCallback((id: AnnotationId | null) => {
setScrollState((prev) => ({ id, version: prev.version + 1 }));
}, []);
const value = useMemo<EngineContextValue>(
() => ({
engine,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
setPendingSelection,
scrollToAnnotationId: scrollState.id,
scrollVersion: scrollState.version,
scrollToAnnotation,
}),
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
);
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
}
export function useEngine(): Engine {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useEngine: missing EngineProvider");
return ctx.engine;
}
export function useActiveDocumentId(): {
readonly id: DocumentId | null;
setId(id: DocumentId | null): void;
} {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useActiveDocumentId: missing EngineProvider");
return { id: ctx.activeDocumentId, setId: ctx.setActiveDocumentId };
}
export function useActiveDocument(): {
readonly document: Document | null;
readonly representation: DocumentRepresentation | null;
} {
const engine = useEngine();
const { id } = useActiveDocumentId();
const [tick, setTick] = useState(0);
// Re-render when documents come and go so list views stay fresh.
useEffect(() => {
const off1 = engine.bus.on("DocumentImported", () => setTick((t) => t + 1));
const off2 = engine.bus.on("DocumentRepresentationGenerated", () => setTick((t) => t + 1));
return () => {
off1();
off2();
};
}, [engine]);
const document = id ? engine.documents.get(id) : null;
const representation = id
? engine.documents.listRepresentations(id).at(-1) ?? null
: null;
// `tick` is intentionally read to silence unused-var warnings; the dep
// chain is via useState so React handles the re-render. We don't actually
// need to consume the value.
void tick;
return { document, representation };
}
/**
* Subscribe to a single engine event type and trigger a re-render each time
* it fires. Returns the current monotonic counter — pure state-marker.
*/
export function useEngineEventTick<T extends Parameters<Engine["bus"]["on"]>[0]>(
type: T,
): number {
const engine = useEngine();
const [tick, setTick] = useState(0);
const bump = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => engine.bus.on(type, bump), [engine, type, bump]);
return tick;
}
export function usePendingSelection(): {
readonly pending: PendingSelection | null;
set(pending: PendingSelection | null): void;
} {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("usePendingSelection: missing EngineProvider");
return { pending: ctx.pendingSelection, set: ctx.setPendingSelection };
}
export function useScrollToAnnotation(): {
readonly id: AnnotationId | null;
readonly version: number;
scrollTo(id: AnnotationId | null): void;
} {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useScrollToAnnotation: missing EngineProvider");
return {
id: ctx.scrollToAnnotationId,
version: ctx.scrollVersion,
scrollTo: ctx.scrollToAnnotation,
};
}