generated from coulomb/repo-seed
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:
40
src/app/App.tsx
Normal file
40
src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
“{f.known_good_quote}”
|
||||
</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" }}>
|
||||
“{pending.capture.text.slice(0, 120)}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { SpikeApp } from "./SpikeApp";
|
||||
export { App } from "./App";
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user