generated from coulomb/repo-seed
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:
233
src/app/SpikeApp.tsx
Normal file
233
src/app/SpikeApp.tsx
Normal 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 }}>
|
||||
“{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 {};
|
||||
export { SpikeApp } from "./SpikeApp";
|
||||
|
||||
12
src/app/main.tsx
Normal file
12
src/app/main.tsx
Normal 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>,
|
||||
);
|
||||
Reference in New Issue
Block a user