Implement CE-WP-0002 T01-T02: engine types + PDF viewer adapter spike

T01: shared engine types (Document, Selector union, Annotation, EvidenceItem,
branded IDs with newId factory) per wiki/SharedContracts.md §1-§3.

T02: react-pdf-highlighter-plus v1.1.4 spike behind the §5
DocumentViewerAdapter contract in src/anchor/. Pure round-trip math
extracted to pdf-selector-math.ts with 11 unit tests proving lossless
capture → selectors → JSON → restored-rects. ADR-0004 accepted; full
user-flow Playwright verification deferred to T09.

Adds Vite app shell (index.html, src/app/SpikeApp.tsx) so the spike is
exercisable via pnpm dev. tsconfig --noEmit prevents tsc -b from
littering src/ with stray .js outputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 02:21:31 +02:00
parent 2f25f99cae
commit 2a7b05c190
22 changed files with 2538 additions and 13 deletions

233
src/app/SpikeApp.tsx Normal file
View File

@@ -0,0 +1,233 @@
/**
* 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 {};
export { SpikeApp } from "./SpikeApp";

12
src/app/main.tsx Normal file
View File

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