feat: extract review-workspace slice into @citation-evidence/work (CWORK-WP-0001)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s

Establishes citation-work as the standalone home of the review workspace,
migrated out of the citation-evidence umbrella app.

- Package/tooling scaffold: package.json, tsconfig, vite/vitest/eslint configs
  with eslint-plugin-boundaries enforcing engine/anchor/source-only imports
- Providers/hooks: EngineProvider, SessionProvider + use* hooks (T02)
- Panes: CollectionList, ViewerShell, EvidenceSidebar (T03/T04/T05)
- Capture/edit: InlineCaptureForm, EvidenceFormBody; UploadDropzone owned here
- ReviewShell: repo-owned three-pane layout with an upload slot seam
- Tests: CollectionList, InlineCaptureForm capture flow, EvidenceSidebar
  export/edit/activation (11 tests, all green)
- Anchor consumed as @citation-evidence/evidence-anchor package; @source kept
  on the umbrella facade for its local viewer-url policy
- Docs (README/SCOPE) refreshed; deferred gaps recorded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-09 01:49:28 +02:00
parent c51ef94812
commit 0cd2ca5d04
30 changed files with 7958 additions and 342 deletions

View file

@ -1,4 +1,4 @@
**Purpose:** Review workspace UI, annotation UX, evidence sidebar. Depends on engine, anchor, source — NOT on binder. INTENT-only during umbrella-first MVP.
**Purpose:** Review workspace UI, annotation UX, evidence sidebar. Depends on engine, anchor, source — NOT on binder. Standalone package scaffold exists; implementation extraction is in progress under `CWORK-WP-0001`.
**Domain:** infotech
**Repo slug:** citation-work

5
.gitignore vendored
View file

@ -7,6 +7,11 @@ __pycache__/
# C extensions
*.so
# Node / TypeScript
node_modules/
*.tsbuildinfo
coverage/
# Distribution / packaging
.Python
build/

View file

@ -2,7 +2,7 @@
## Repo Identity
**Purpose:** Review workspace UI, annotation UX, evidence sidebar. Depends on engine, anchor, source — NOT on binder. INTENT-only during umbrella-first MVP.
**Purpose:** Review workspace UI, annotation UX, evidence sidebar. Depends on engine, anchor, source — NOT on binder. Standalone package scaffold exists; implementation extraction is in progress under `CWORK-WP-0001`.
**Domain:** infotech
**Repo slug:** citation-work

View file

@ -2,16 +2,78 @@
A document review workspace — the React surfaces for the review workflow:
collection list, viewer shell, evidence sidebar, annotation create flow,
form-binding side-by-side layout.
and review-oriented upload/navigation shell.
## MVP status: INTENT only
Published as the `@citation-evidence/work` package and consumed by the
`citation-evidence` umbrella app's review mode.
During the citation-evidence MVP, code lives upstream in
[`citation-evidence`](../citation-evidence/) under `src/work/`. This repo
currently holds `INTENT.md` describing what will move here. Contract
changes belong in
[`citation-evidence/wiki/SharedContracts.md`](../citation-evidence/wiki/SharedContracts.md),
not here.
## Current state
Per the dependency map, work depends on `shared/`, `engine/`, `anchor/`,
and `source/` — and **may not** import from `binder/`.
This repo is now the **code of record** for the review workspace. The slice was
extracted from the umbrella app under `CWORK-WP-0001`; `citation-evidence` no
longer carries a local `src/work/` implementation and instead depends on this
package (its `@work` alias resolves here).
Package surface (`src/work/index.ts`):
- **Shell:** `ReviewShell` — the three-pane review layout (collection · viewer · sidebar)
- **Panes:** `CollectionList`, `ViewerShell`, `EvidenceSidebar`
- **Capture/edit:** `InlineCaptureForm`, `EvidenceFormBody`
- **Upload:** `UploadDropzone` (also the default `ReviewShell` upload slot)
- **Providers/hooks:** `EngineProvider`, `SessionProvider`, and the associated
`use*` hooks; `useExportEvidence`; `useDebugFlag`
### Upload seam decision (T03)
`UploadDropzone` is owned by this package — it depends only on `@source` and the
local engine/session hooks, so the shell renders a complete review surface with
no umbrella-local imports. `ReviewShell` still exposes an `upload` slot so a
consumer can substitute its own affordance; when omitted, the package default is
used.
## Boundary
`citation-work` depends on:
- [`@citation-evidence/engine`](../citation-engine/)
- [`@citation-evidence/evidence-anchor`](../evidence-anchor/)
- `@source` (PDF ingestion + viewer-url policy)
It may **not** import from binder-owned or umbrella-app-owned review-to-form
code. The dependency edges are enforced by `eslint-plugin-boundaries` in
`eslint.config.js`.
## Alias / extraction notes
`citation-engine` and `evidence-anchor` are extracted sibling packages and are
consumed by their package names. `@source/*` still resolves into the umbrella's
`src/source` façade because that façade owns the local `viewer-url` policy that
is not part of the extracted `evidence-source` package. This is the one
remaining transitional edge; see the deferred list below.
## Deferred after this extraction
The following were intentionally **not** part of `CWORK-WP-0001` and remain for a
subsequent workplan slice (see the workplan's "Deferred After This Workplan"
section for the authoritative list):
- collection search / filter UX,
- document review-status interactions,
- evidence-item status / tagging / filtering UX,
- binder-integrated review-to-form workflows,
- new viewer/document-format features beyond the current PDF slice,
- retiring the `@source` façade edge once a `viewer-url` home is decided (either
a small local module here or an `evidence-source` addition).
## Verification
```bash
pnpm install
pnpm typecheck
pnpm lint
pnpm test
```
## Workplan
- [CWORK-WP-0001](workplans/CWORK-WP-0001-extract-review-workspace.md)

View file

@ -8,110 +8,94 @@
## One-liner
<!-- Describe the purpose of this repository in one precise sentence. -->
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
Provides the standalone review-workspace package for citation-evidence: collection pane, viewer shell, annotation capture flow, and evidence sidebar.
---
## Core Idea
<!-- What is the main capability or idea behind this repository? -->
<!-- What problem does it try to solve? -->
Extract the human-facing review workflow out of the umbrella app so the review
workspace can evolve as its own package with clear subsystem boundaries.
---
## In Scope
<!-- What this repository is responsible for. -->
<!-- Be explicit and concrete. -->
-
-
-
- Review-mode React surfaces: collection list, viewer shell, evidence sidebar, capture/edit flow
- Workspace-local providers, hooks, and package exports for the review slice
- Review-shell composition and related tests/docs
---
## Out of Scope
<!-- What this repository deliberately does NOT do. -->
<!-- This is often more important than "In Scope". -->
-
-
-
- Engine domain model, persistence contracts, and citation renderers
- Selector-resolution internals and document-ingestion internals
- Binder-owned evidence-to-form workflows and form UI composition
---
## Relevant When
<!-- When should someone consider using or exploring this repository? -->
-
-
-
- You are extracting or changing the review workspace from `citation-evidence`
- You need collection/viewer/sidebar UX without pulling binder concerns into the package
- You are defining the package surface that the umbrella app will consume for review mode
---
## Not Relevant When
<!-- When should someone ignore this repository? -->
-
-
-
- You are changing engine contracts or storage semantics
- You are working on selector algorithms or PDF/HTML/Markdown ingestion internals
- You are building form-binding, overlays, or evidence-link workflows
---
## Current State
<!-- Rough indication of maturity. No strict format required. -->
- Status: extracted (CWORK-WP-0001 complete)
- Implementation: standalone `@citation-evidence/work` package; review-workspace
slice migrated out of the umbrella app
- Stability: evolving
- Usage: internal only
- Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
- Implementation: <!-- e.g. idea / partial / substantial / complete -->
- Stability: <!-- e.g. unstable / evolving / stable -->
- Usage: <!-- e.g. none / personal / internal / production -->
<!-- Add any notes that help set expectations. -->
This repo is now the code of record for the review workspace. The umbrella app's
`@work` alias resolves here and it no longer carries a local `src/work/`
implementation.
---
## How It Fits
<!-- Where does this repository sit in the bigger picture? -->
- Upstream dependencies:
- Downstream consumers:
- Often used with:
- Upstream dependencies: `citation-engine`, anchor contracts, source contracts
- Downstream consumers: `citation-evidence` umbrella app review mode
- Often used with: `citation-engine`, future `evidence-anchor`, future `evidence-source`
---
## Terminology
<!-- Terms that are important to understand this repo. -->
<!-- Especially useful if naming differs from other repos. -->
- Preferred terms:
- Also known as:
- Potentially confusing terms:
- Preferred terms: review workspace, collection pane, viewer shell, evidence sidebar
- Also known as: formerly `src/work/` in the umbrella repo (now extracted here)
- Potentially confusing terms: binder/form workflows are adjacent but not owned here
---
## Related / Overlapping Repositories
<!-- List repositories that have similar or adjacent responsibilities. -->
<!-- Helps detect duplication and navigate the ecosystem. -->
- <repo-name><!-- how it relates -->
- `citation-evidence` — umbrella app that currently hosts the source implementation
- `citation-engine` — shared types, services, persistence, rendering
- `evidence-anchor` — selector creation/resolution and viewer-adapter contracts
- `evidence-source` — document ingestion and source-side PDF helpers
- `evidence-binder` — adjacent but intentionally not a dependency
---
## Getting Oriented
<!-- If someone decides to look deeper, where should they start? -->
- Start with:
- Key files / directories:
- Entry points:
- Start with: `workplans/CWORK-WP-0001-extract-review-workspace.md`
- Key files / directories: `README.md`, `INTENT.md`, `src/work/`
- Entry points: `src/work/index.ts` (the `@citation-evidence/work` package barrel)
---
@ -134,4 +118,8 @@ keywords: [keyword1, keyword2, keyword3]
## Notes
<!-- Anything else worth knowing. Keep it short. -->
Anchor is consumed as the extracted `@citation-evidence/evidence-anchor`
package. `@source/*` still resolves into `../citation-evidence/src/source`
(the umbrella façade) because that façade owns the local `viewer-url` policy not
present in the extracted `evidence-source` package — the one remaining
transitional edge, tracked in the workplan's deferred list.

71
eslint.config.js Normal file
View file

@ -0,0 +1,71 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import boundaries from "eslint-plugin-boundaries";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const engineSrc = resolve(__dirname, "../citation-engine/src");
const anchorSrc = resolve(__dirname, "../evidence-anchor/src");
const sourceSrc = resolve(__dirname, "../citation-evidence/src/source");
export default tseslint.config(
{
ignores: ["dist/", "node_modules/", "coverage/", "**/*.d.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { ...globals.browser, ...globals.node },
},
plugins: {
boundaries,
import: importPlugin,
},
settings: {
"import/resolver": {
typescript: { project: "./tsconfig.json" },
},
"boundaries/elements": [
{ type: "shared", pattern: `${engineSrc}/shared/**` },
{ type: "engine", pattern: `${engineSrc}/engine/**` },
{ type: "anchor", pattern: `${anchorSrc}/**` },
{ type: "source", pattern: `${sourceSrc}/**` },
{ type: "work", pattern: "src/work/**" },
],
},
rules: {
"boundaries/element-types": [
2,
{
default: "disallow",
rules: [
{ from: "shared", allow: [] },
{ from: "engine", allow: ["shared"] },
{ from: "anchor", allow: ["shared", "engine"] },
{ from: "source", allow: ["shared", "engine"] },
{ from: "work", allow: ["shared", "engine", "anchor", "source", "work"] },
],
},
],
"no-restricted-imports": [
2,
{
patterns: [
"@binder/*",
"@app/*",
"../citation-evidence/src/app/*",
"../citation-evidence/src/binder/*",
"../citation-evidence/src/work/*",
],
},
],
},
},
);

60
package.json Normal file
View file

@ -0,0 +1,60 @@
{
"name": "@citation-evidence/work",
"version": "0.0.0",
"private": true,
"description": "Review workspace UI for the citation-evidence ecosystem.",
"license": "Apache-2.0",
"type": "module",
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=20.10.0"
},
"files": [
"src",
"README.md",
"LICENSE",
"SCOPE.md",
"INTENT.md"
],
"exports": {
".": "./src/work/index.ts"
},
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine",
"@citation-evidence/evidence-anchor": "link:../evidence-anchor",
"@citation-evidence/evidence-source": "link:../evidence-source",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-pdf-highlighter-plus": "^1.1.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.7.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-boundaries": "^4.2.2",
"eslint-plugin-import": "^2.30.0",
"globals": "^15.9.0",
"happy-dom": "^20.9.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"vite": "^5.4.0",
"vite-plugin-static-copy": "^2",
"vitest": "^2.0.5"
}
}

4606
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import { CollectionList, EngineProvider, useEngine, usePdfByteStore } from "./index";
function makeDoc(suffix: string): { document: Document; representation: DocumentRepresentation } {
const id = `doc_${suffix}` as DocumentId;
const repId = `rep_${suffix}` as RepresentationId;
return {
document: {
id,
mediaType: "application/pdf",
title: `Doc ${suffix}`,
fingerprint: `hash-${suffix}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: id,
representationType: "pdf-text",
contentHash: `hash-${suffix}`,
canonicalText: `body ${suffix}`,
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 6, pageLength: 6 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
function Seed() {
const engine = useEngine();
const store = usePdfByteStore();
if (engine.documents.list().length === 0) {
const a = makeDoc("alpha");
const b = makeDoc("beta");
store.put(a.document.id, new Uint8Array([1, 2]));
store.put(b.document.id, new Uint8Array([3, 4]));
engine.documents.register(a);
engine.documents.register(b);
}
return null;
}
beforeEach(() => {
globalThis.localStorage?.clear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("CollectionList (session-scoped)", () => {
it("renders one row per registered document", async () => {
render(
<EngineProvider>
<Seed />
<CollectionList title="Demo session" />
</EngineProvider>,
);
await waitFor(() => {
expect(screen.getByText("Doc alpha")).toBeTruthy();
expect(screen.getByText("Doc beta")).toBeTruthy();
});
expect(screen.getByText("Demo session")).toBeTruthy();
});
it(
"per-row delete asks for confirmation, then removes the row and revokes the blob URL",
{ timeout: 8000 },
async () => {
let revokedUrl: string | null = null;
// Patch URL.revokeObjectURL so we can confirm the byte store fired it.
const original = URL.revokeObjectURL;
URL.revokeObjectURL = (url: string) => {
revokedUrl = url;
};
try {
render(
<EngineProvider>
<Seed />
<CollectionList />
</EngineProvider>,
);
await screen.findByText("Doc alpha");
const user = userEvent.setup();
const deleteBtn = await screen.findByTestId("collection-delete-doc_alpha");
// First click → confirm prompt
await user.click(deleteBtn);
expect(deleteBtn.textContent).toContain("Confirm");
// Second click → commit
await user.click(deleteBtn);
await waitFor(() => {
expect(screen.queryByText("Doc alpha")).toBeNull();
});
expect(revokedUrl).not.toBeNull();
expect(revokedUrl!).toMatch(/^blob:/);
} finally {
URL.revokeObjectURL = original;
}
},
);
});

166
src/work/CollectionList.tsx Normal file
View file

@ -0,0 +1,166 @@
/**
* CollectionList the left pane.
*
* CE-WP-0005 turned this into a *session-scoped* list. It shows the
* documents currently registered with the active session's engine,
* with per-row Open + Delete actions and an inline upload affordance.
*
* Fixture-driven quick-start lives in
* `src/app/sessions/SampleSessions.tsx` and is no longer the default.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import type { ReactNode } from "react";
import type { DocumentId } from "@shared/ids";
import {
useActiveDocumentId,
useEngine,
useEngineEventTick,
useEngineRevision,
usePdfByteStore,
} from "./EngineContext";
export interface CollectionListProps {
/**
* Slot rendered above the list typically the upload affordance.
* Kept as a slot so this component stays in `work/` (which cannot
* import `app/`).
*/
readonly upload?: ReactNode;
/** Optional session header text — typically the active session name. */
readonly title?: string;
}
export function CollectionList({ upload, title }: CollectionListProps) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { id: activeId, setId } = useActiveDocumentId();
const importedTick = useEngineEventTick("DocumentImported");
const removedTick = useEngineEventTick("DocumentRemoved");
const revision = useEngineRevision();
const documents = useMemo(
() => engine.documents.list(),
[engine, importedTick, removedTick, revision],
);
// Confirm-on-delete UX without a modal: clicking Delete asks "Confirm?",
// a second click within ~3s commits. Esc clears the pending state.
const [pendingDeleteId, setPendingDeleteId] = useState<DocumentId | null>(null);
useEffect(() => {
if (!pendingDeleteId) return;
const t = setTimeout(() => setPendingDeleteId(null), 3000);
return () => clearTimeout(t);
}, [pendingDeleteId]);
const handleDelete = useCallback(
(id: DocumentId) => {
if (pendingDeleteId !== id) {
setPendingDeleteId(id);
return;
}
// Active doc was just deleted — clear the pointer so the viewer
// unmounts before the engine drops the record.
if (activeId === id) setId(null);
byteStore.delete(id);
engine.documents.remove(id);
setPendingDeleteId(null);
},
[activeId, byteStore, engine, pendingDeleteId, setId],
);
return (
<aside
style={{
width: 280,
borderRight: "1px solid #ddd",
padding: 12,
overflow: "auto",
flex: "0 0 280px",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>
{title ?? "Collection"}
</h2>
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
{documents.length} document{documents.length === 1 ? "" : "s"}
</p>
{upload && <div style={{ marginBottom: 8 }}>{upload}</div>}
{documents.length === 0 && !upload && (
<p style={{ fontSize: 12, color: "#888" }}>
No documents yet. Upload a PDF to get started.
</p>
)}
<ul
data-testid="collection-list-items"
style={{ listStyle: "none", padding: 0, margin: 0 }}
>
{documents.map((doc) => {
const isActive = doc.id === activeId;
const isPending = pendingDeleteId === doc.id;
return (
<li key={doc.id} style={{ marginBottom: 6 }}>
<div
style={{
// Always-blue palette to mirror the evidence-card
// "always-yellow + thicker border when active" pattern.
border: isActive ? "3px solid #0050b3" : "1px solid #0050b3",
background: "#e8f0ff",
display: "flex",
flexDirection: "column",
fontSize: 12,
}}
data-testid={`collection-item-${doc.id}`}
data-active={isActive ? "true" : "false"}
>
<button
onClick={() => setId(doc.id)}
data-testid={`collection-open-${doc.id}`}
style={openButtonStyle}
>
<div style={{ fontWeight: 600 }}>{doc.title ?? doc.id}</div>
<div style={{ color: "#666", fontSize: 11 }}>
{doc.id}
{isActive ? " · open" : ""}
</div>
</button>
<div style={{ display: "flex", justifyContent: "flex-end", padding: 4, gap: 4 }}>
<button
type="button"
onClick={() => handleDelete(doc.id)}
data-testid={`collection-delete-${doc.id}`}
style={{
fontSize: 11,
padding: "2px 8px",
border: "1px solid #b00",
background: isPending ? "#ffe5e5" : "white",
color: "#7a0000",
cursor: "pointer",
}}
>
{isPending ? "Confirm delete?" : "Delete"}
</button>
</div>
</div>
</li>
);
})}
</ul>
</aside>
);
}
const openButtonStyle: CSSProperties = {
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
cursor: "pointer",
fontSize: 12,
};

316
src/work/EngineContext.tsx Normal file
View file

@ -0,0 +1,316 @@
/**
* 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, SessionId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import {
attachPersister,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type Engine,
} from "@engine/index";
import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { useContext as useReactContext } from "react";
import { SessionInternalContext } from "./SessionContextInternal";
/**
* Legacy single-bucket storage keys, kept for any user landing on a
* build without sessions. CE-WP-0005 switched persistence to per-session
* keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are
* only consulted when no `sessionId` is provided to the provider.
*/
const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
function storageKeyFor(sessionId: SessionId | null): string {
return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY;
}
function activeDocumentKeyFor(sessionId: SessionId | null): string {
return sessionId
? `citation-evidence:session:${sessionId}:active-document-id:v1`
: ACTIVE_KEY;
}
/**
* 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 byteStore: PdfByteStore;
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;
/**
* Bumps each time the engine's repos are mutated outside the normal
* event-emitting service path currently only on `restoreFromStorage`.
* Consumers that cache `engine.documents.list()` via `useMemo` add this
* to their deps so the restored state is reflected on remount.
*/
readonly engineRevision: number;
}
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;
/**
* Active session id. Drives the per-session storage key for the engine
* snapshot and the active-document pointer. `null`/omitted falls back
* to the legacy unscoped keys for back-compat with pre-CE-WP-0005
* builds.
*
* To switch sessions, parents should *re-key* this provider
* (`<EngineProvider key={sessionId} sessionId={sessionId}>`) so React
* unmounts the subtree and a fresh engine is created.
*/
readonly sessionId?: SessionId | null;
}
export function EngineProvider({
children,
engine: injected,
sessionId = null,
}: EngineProviderProps) {
const engine = useMemo(() => injected ?? createEngine(), [injected]);
// Prefer the SessionProvider's per-session byte store registry when
// available; fall back to a provider-local store for tests that mount
// EngineProvider on its own.
const sessionCtx = useReactContext(SessionInternalContext);
const [fallbackByteStore] = useState<PdfByteStore>(() => createPdfByteStore());
const byteStore =
sessionId && sessionCtx
? sessionCtx.getOrCreateByteStore(sessionId)
: fallbackByteStore;
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
// `restoreFromStorage` writes directly to the engine's repos without
// firing engine events (by design — see persistence.ts). That means
// consuming components (CollectionList etc.) wouldn't normally
// re-render to reflect the restored state. Bumping `engineRevision`
// after restore is what consumers add to their `useMemo` deps so
// the restored state shows up on (re-)mount.
const [engineRevision, setEngineRevision] = useState(0);
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
id: null,
version: 0,
});
const snapshotKey = storageKeyFor(sessionId);
const activeDocKey = activeDocumentKeyFor(sessionId);
// 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: snapshotKey });
if (result.restored) {
const saved = globalThis.localStorage.getItem(activeDocKey);
if (saved && engine.documents.get(saved as DocumentId)) {
setActiveDocumentIdState(saved as DocumentId);
}
// Force a re-render so consumers see the restored repos.
setEngineRevision((n) => n + 1);
}
return attachPersister(engine, { key: snapshotKey });
}, [engine, injected, snapshotKey, activeDocKey]);
// 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(activeDocKey, activeDocumentId);
} else {
globalThis.localStorage.removeItem(activeDocKey);
}
}, [activeDocumentId, injected, activeDocKey]);
// 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,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
setPendingSelection,
scrollToAnnotationId: scrollState.id,
scrollVersion: scrollState.version,
scrollToAnnotation,
engineRevision,
}),
[
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
scrollState,
scrollToAnnotation,
engineRevision,
],
);
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 usePdfByteStore(): PdfByteStore {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider");
return ctx.byteStore;
}
export function useEngineRevision(): number {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useEngineRevision: missing EngineProvider");
return ctx.engineRevision;
}
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,
};
}
/**
* Track the most-recent `EvidenceItemActivated` event id from the engine
* bus. Returns `null` until something is activated. UI components that
* highlight "the active evidence" subscribe via this hook so they don't
* need to import the binder's active-state machine directly.
*/
export function useLastActivatedEvidence(): import("@shared/ids").EvidenceItemId | null {
const engine = useEngine();
const [id, setId] = useState<import("@shared/ids").EvidenceItemId | null>(null);
useEffect(() => {
return engine.bus.on("EvidenceItemActivated", (e) => setId(e.evidenceItemId));
}, [engine]);
return id;
}

View file

@ -0,0 +1,104 @@
/**
* EvidenceFormBody the shared "citation + commentary" editor.
*
* One form drives both:
* - the *capture* flow (creating evidence from a fresh selection
* inside `InlineCaptureForm`), and
* - the *edit* flow (modifying an existing evidence card inside
* `EvidenceSidebar`).
*
* Keeping a single component means changes to the field layout, hint
* placement, or save-button copy land in one file and apply to both
* surfaces. Callers control the labels and any badge/helper text.
*/
import type { CSSProperties, ReactNode } from "react";
export interface EvidenceFormBodyProps {
readonly quote: string;
readonly commentary: string;
onChangeQuote(next: string): void;
onChangeCommentary(next: string): void;
onSave(): void;
onCancel(): void;
/** Save-button label, defaults to "Save". */
readonly saveLabel?: string;
/** Cancel-button label, defaults to "Cancel". */
readonly cancelLabel?: string;
/** Short caption rendered at the top of the form, e.g. a selector
* count badge or "Editing". */
readonly badge?: ReactNode;
/** Inline note shown under the buttons (e.g. "won't move the
* marked passage" when editing an existing item). */
readonly helper?: ReactNode;
/** data-testid prefix for the input + button hooks. */
readonly testidPrefix: string;
}
export function EvidenceFormBody(p: EvidenceFormBodyProps) {
const saveLabel = p.saveLabel ?? "Save";
const cancelLabel = p.cancelLabel ?? "Cancel";
return (
<div style={{ padding: 8, fontSize: 12 }}>
{p.badge && (
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
)}
<label style={labelStyle}>Citation text</label>
<textarea
value={p.quote}
onChange={(e) => p.onChangeQuote(e.target.value)}
data-testid={`${p.testidPrefix}-quote`}
rows={3}
style={{
...textareaStyle,
fontStyle: "italic",
}}
/>
<label style={labelStyle}>Commentary</label>
<textarea
value={p.commentary}
onChange={(e) => p.onChangeCommentary(e.target.value)}
data-testid={`${p.testidPrefix}-commentary`}
rows={2}
placeholder="(optional)"
style={textareaStyle}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button
type="button"
onClick={p.onSave}
data-testid={`${p.testidPrefix}-save`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
{saveLabel}
</button>
<button
type="button"
onClick={p.onCancel}
data-testid={`${p.testidPrefix}-cancel`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
{cancelLabel}
</button>
{p.helper && (
<span style={{ fontSize: 11, color: "#888" }}>{p.helper}</span>
)}
</div>
</div>
);
}
const labelStyle: CSSProperties = {
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
};
const textareaStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
};

View file

@ -0,0 +1,248 @@
/**
* EvidenceSidebar export-flow tests (CE-WP-0004-T04).
*
* Covers:
* - Export popover opens on click, exposes Markdown + HTML options.
* - Copy as Markdown writes the rendered card to navigator.clipboard
* and shows a success toast.
* - Copy as HTML writes the HTML card.
* - Clipboard failure surfaces an error toast.
* - Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
*
* Uses a real engine (not a mock) so the renderer + service plumbing is
* exercised end-to-end up to the clipboard boundary.
*/
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEngine, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import { newId } from "@shared/ids";
import { EngineProvider, EvidenceSidebar } from "./index";
// happy-dom ships a real `navigator.clipboard.writeText` that stashes
// the text into an internal Blob. We spy on the prototype method so
// every test's clicks route through our mock without fighting the
// Navigator class's `#clipboard` private field. `mockImplementation`
// per test swaps in the success or failure behaviour.
let writeText: ReturnType<typeof vi.spyOn> &
((text: string) => Promise<void>);
let lastEngine: Engine | null = null;
let activeDocumentId: DocumentId | null = null;
function seedEngine(): { engine: Engine; documentId: DocumentId } {
const engine = createEngine();
const now = "2026-05-25T00:00:00.000Z";
const { document } = engine.documents.register({
document: {
id: newId("document"),
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
fingerprint: "test-fingerprint",
createdAt: now,
updatedAt: now,
},
representation: {
id: newId("representation"),
documentId: newId("document"),
representationType: "pdf-text",
contentHash: "test-fingerprint",
canonicalText: "Die Frist endet am 31. März 2024.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 34, pageLength: 34 }],
generatedAt: now,
},
});
const annotation = engine.annotations.create({
documentId: document.id,
selectors: [
{ type: "TextQuoteSelector", exact: "Die Frist endet am 31. März 2024." },
],
quote: "Die Frist endet am 31. März 2024.",
});
engine.evidence.create({
annotationIds: [annotation.id],
commentary: "Deadline clause for the buyer.",
});
return { engine, documentId: document.id };
}
function Harness({ engine, documentId }: { engine: Engine; documentId: DocumentId }) {
lastEngine = engine;
activeDocumentId = documentId;
return (
<EngineProvider engine={engine}>
<ActiveDocumentSetter documentId={documentId}>
<EvidenceSidebar />
</ActiveDocumentSetter>
</EngineProvider>
);
}
import { useEffect } from "react";
import { useActiveDocumentId } from "./EngineContext";
function ActiveDocumentSetter({
documentId,
children,
}: {
documentId: DocumentId;
children: React.ReactNode;
}) {
const { setId } = useActiveDocumentId();
useEffect(() => {
setId(documentId);
}, [documentId, setId]);
return <>{children}</>;
}
function installClipboard(impl: (text: string) => Promise<void>) {
writeText = vi.fn(impl) as unknown as typeof writeText;
// happy-dom recreates its Clipboard prototype across the
// beforeEach → render boundary in some test passes, so patches
// applied earlier (e.g. in beforeEach) get silently discarded.
// Patching the *current* prototype right before the action under
// test (i.e. after render) is reliable.
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
describe("EvidenceSidebar — export flow (CE-WP-0004-T04)", () => {
beforeEach(() => {
lastEngine = null;
activeDocumentId = null;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders an Export toggle for each evidence item", async () => {
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
const toggle = await screen.findByLabelText("Export evidence item");
expect(toggle).toBeTruthy();
});
it("opens the menu and copies Markdown to the clipboard", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain("> Die Frist endet am 31. März 2024.");
expect(written).toContain("— *Order from 14 Mar 2024*");
expect(written).toContain("[Open source](/viewer?document=");
expect(written).toContain("Deadline clause for the buyer.");
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("success");
expect(toast.textContent).toContain("Copied as Markdown");
});
it("copies HTML when the HTML menu item is clicked", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as HTML" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain('<aside class="citation-card">');
expect(written).toContain('<blockquote class="citation-card__quote">');
const toast = await screen.findByTestId("export-toast");
expect(toast.textContent).toContain("Copied as HTML");
});
it("surfaces a clipboard write failure via the error toast", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => {
throw new Error("denied");
});
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("error");
expect(toast.textContent).toMatch(/clipboard write was rejected|Copy failed/);
});
it("Cmd+Shift+C exports the active evidence as Markdown", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
// Activate the item first by clicking the card body. The card body is
// the button that contains the quote/commentary, not the Export toggle.
const itemButton = await screen.findByText(/Deadline clause for the buyer/);
await user.click(itemButton);
expect(writeText).not.toHaveBeenCalled();
await user.keyboard("{Control>}{Shift>}c{/Shift}{/Control}");
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain("> Die Frist endet am 31. März 2024.");
const toast = await screen.findByTestId("export-toast");
expect(toast.textContent).toContain("Copied as Markdown");
});
it("edits an evidence item's quote and commentary in place", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
const itemId = engine.evidence.listByDocument(documentId)[0]!.id;
// Open the inline edit form for the (single) evidence card.
await user.click(screen.getByLabelText("Edit citation and commentary"));
const commentary = await screen.findByTestId(
`evidence-edit-${itemId}-commentary`,
);
await user.clear(commentary);
await user.type(commentary, "Revised deadline note.");
const quote = screen.getByTestId(`evidence-edit-${itemId}-quote`);
await user.clear(quote);
await user.type(quote, "Neue Frist: 30. April 2024.");
await user.click(screen.getByTestId(`evidence-edit-${itemId}-save`));
// Edit form closes and the engine reflects the new quote + commentary.
await waitFor(() => {
expect(
screen.queryByTestId(`evidence-edit-${itemId}-commentary`),
).toBeNull();
});
const item = engine.evidence.listByDocument(documentId)[0]!;
expect(item.commentary).toBe("Revised deadline note.");
const annotationId = item.annotationIds[0]!;
const annotation = engine.annotations.get(annotationId);
expect(annotation?.quote).toBe("Neue Frist: 30. April 2024.");
await screen.findByText(/Revised deadline note/);
});
});
// Silence "unused" warnings for the test-scope captures we kept for
// debugging — TypeScript would otherwise complain.
void lastEngine;
void activeDocumentId;

View file

@ -0,0 +1,526 @@
/**
* EvidenceSidebar the right pane.
*
* Lists `EvidenceItem`s scoped to the active document, sorted by their
* position in the document (first PdfRectSelector's page + y). Each row:
*
* - Click activates the evidence item (highlights its passage in
* the viewer + thickens its border).
* - Edit pencil inline form to change the citation quote and
* commentary. The underlying selectors stay untouched, so the
* marked passage in the document doesn't move.
* - Export popover copy as Markdown / HTML (CE-WP-0004).
*
* The "create new evidence from a fresh selection" form
* (`InlineCaptureForm`) is slotted into the list at the right
* document-flow position whenever there is a pending selection so a
* new capture appears between the cards that bracket it, or at the
* top/bottom if it's the first or last passage in the document.
*
* Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
*/
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import type { Annotation } from "@shared/annotation";
import type { EvidenceItem } from "@shared/evidence";
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
import type { PdfRectSelector, Selector } from "@shared/selector";
import {
useActiveDocument,
useEngine,
useEngineEventTick,
useEngineRevision,
useLastActivatedEvidence,
usePendingSelection,
useScrollToAnnotation,
} from "./EngineContext";
import {
useExportEvidence,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
import { EvidenceFormBody } from "./EvidenceFormBody";
import { InlineCaptureForm } from "./InlineCaptureForm";
const TOAST_TIMEOUT_MS = 2000;
export interface EvidenceSidebarProps {
onActivate?(item: EvidenceItem): void;
}
interface ToastState {
readonly message: string;
readonly tone: "success" | "error";
readonly key: number;
}
function describeError(result: Extract<ExportResult, { ok: false }>): string {
switch (result.reason) {
case "no-annotation":
case "annotation-missing":
return "Cannot export: no source annotation.";
case "document-missing":
return "Cannot export: source document missing.";
case "clipboard-unavailable":
return "Clipboard not available in this browser.";
case "clipboard-write-failed":
return "Copy failed — clipboard write was rejected.";
}
}
function describeSuccess(format: ExportFormat): string {
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
}
/**
* A sortable scalar key for "where in the document is this passage".
* Page-first, then y-coordinate (0..1 within the page). Returns
* Infinity for items without a usable position so they sink to the
* bottom. The same scheme is used for `EvidenceItem`s (via their
* first annotation) and for the pending selection's capture.
*/
function docOrderKey(selectors: readonly Selector[]): number {
for (const s of selectors) {
if (s.type === "PdfRectSelector") {
const rect: PdfRectSelector = s;
const top = rect.rects[0]?.y ?? 0;
return rect.page * 1000 + top;
}
}
return Number.POSITIVE_INFINITY;
}
function annotationOrderKey(annotation: Annotation | null): number {
if (!annotation) return Number.POSITIVE_INFINITY;
return docOrderKey(annotation.selectors);
}
export function EvidenceSidebar(props: EvidenceSidebarProps) {
const engine = useEngine();
const { document } = useActiveDocument();
const { scrollTo } = useScrollToAnnotation();
const activeId = useLastActivatedEvidence();
const { exportItem } = useExportEvidence();
const { pending } = usePendingSelection();
const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated");
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
const revision = useEngineRevision();
// Build the sorted view-model: each item gets its order key + the
// first annotation up-front so the render below doesn't have to
// re-resolve them inside the map.
const sortedItems = useMemo(() => {
if (!document) return [] as readonly { item: EvidenceItem; annotation: Annotation | null; order: number }[];
const items = engine.evidence.listByDocument(document.id);
const out = items.map((item) => {
const firstAnnId = item.annotationIds[0];
const annotation = firstAnnId ? engine.annotations.get(firstAnnId) : null;
return { item, annotation, order: annotationOrderKey(annotation) };
});
out.sort((a, b) => a.order - b.order);
return out;
}, [
document,
engine,
createTick,
updateTick,
annotationUpdateTick,
revision,
]);
const pendingOrder = useMemo<number>(() => {
if (!pending) return Number.POSITIVE_INFINITY;
const c = pending.capture;
return c.page * 1000 + (c.boundingRect?.y ?? 0);
}, [pending]);
// Find the insert position for the pending capture form: first index
// whose order > pendingOrder, or sortedItems.length to append.
const pendingInsertIndex = useMemo(() => {
if (!pending) return -1;
for (let i = 0; i < sortedItems.length; i++) {
if (sortedItems[i]!.order > pendingOrder) return i;
}
return sortedItems.length;
}, [pending, pendingOrder, sortedItems]);
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
const [editingId, setEditingId] = useState<EvidenceItemId | null>(null);
const [editQuote, setEditQuote] = useState("");
const [editCommentary, setEditCommentary] = useState("");
const [toast, setToast] = useState<ToastState | null>(null);
const toastKeyRef = useRef(0);
const showToast = useCallback((message: string, tone: "success" | "error") => {
toastKeyRef.current += 1;
const key = toastKeyRef.current;
setToast({ message, tone, key });
}, []);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => {
setToast((current) => (current && current.key === toast.key ? null : current));
}, TOAST_TIMEOUT_MS);
return () => clearTimeout(t);
}, [toast]);
const runExport = useCallback(
async (item: EvidenceItem, format: ExportFormat) => {
const result = await exportItem(item, format);
if (result.ok) showToast(describeSuccess(result.format), "success");
else showToast(describeError(result), "error");
},
[exportItem, showToast],
);
// Cmd/Ctrl+Shift+C: export the active evidence as Markdown.
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: KeyboardEvent) => {
const modifier = e.metaKey || e.ctrlKey;
if (!modifier || !e.shiftKey) return;
if (e.key !== "C" && e.key !== "c") return;
if (!activeId) return;
const item = engine.evidence.get(activeId);
if (!item) return;
e.preventDefault();
void runExport(item, "markdown");
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [activeId, engine, runExport]);
const activateItem = useCallback(
(item: EvidenceItem, firstAnnotationId: AnnotationId | undefined) => {
engine.evidence.activate(item.id, "sidebar");
if (firstAnnotationId) scrollTo(firstAnnotationId);
props.onActivate?.(item);
},
[engine, scrollTo, props],
);
const beginEdit = useCallback(
(item: EvidenceItem, annotation: Annotation | null) => {
setEditingId(item.id);
setEditQuote(annotation?.quote ?? "");
setEditCommentary(item.commentary ?? "");
setOpenExportFor(null);
},
[],
);
const cancelEdit = useCallback(() => {
setEditingId(null);
}, []);
const saveEdit = useCallback(
(item: EvidenceItem, annotation: Annotation | null) => {
try {
if (annotation) {
engine.annotations.updateQuote(annotation.id, editQuote);
}
// updateCommentary expects a string — empty string clears it.
engine.evidence.updateCommentary(item.id, editCommentary);
setEditingId(null);
} catch (err) {
showToast(
err instanceof Error ? `Save failed: ${err.message}` : "Save failed",
"error",
);
}
},
[engine, editQuote, editCommentary, showToast],
);
return (
<aside
style={{
width: 320,
borderLeft: "1px solid #ddd",
padding: 12,
overflow: "auto",
flex: "0 0 320px",
fontFamily: "system-ui, sans-serif",
position: "relative",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Evidence</h2>
{!document && (
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
)}
{document && sortedItems.length === 0 && !pending && (
<p style={{ fontSize: 12, color: "#888" }}>
No evidence yet. Drag-select a passage in the viewer to start a
capture.
</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{sortedItems.map((entry, i) => {
const slotForCapture = pendingInsertIndex === i;
return (
<Fragment key={entry.item.id}>
{slotForCapture && (
<li>
<InlineCaptureForm />
</li>
)}
<li style={{ marginBottom: 8 }}>
<EvidenceCard
item={entry.item}
annotation={entry.annotation}
isActive={activeId === entry.item.id}
isExportOpen={openExportFor === entry.item.id}
isEditing={editingId === entry.item.id}
editQuote={editQuote}
editCommentary={editCommentary}
onActivate={() =>
activateItem(entry.item, entry.annotation?.id)
}
onBeginEdit={() => beginEdit(entry.item, entry.annotation)}
onChangeQuote={setEditQuote}
onChangeCommentary={setEditCommentary}
onSaveEdit={() => saveEdit(entry.item, entry.annotation)}
onCancelEdit={cancelEdit}
onToggleExport={() =>
setOpenExportFor((current) =>
current === entry.item.id ? null : entry.item.id,
)
}
onCopyMarkdown={async () => {
setOpenExportFor(null);
await runExport(entry.item, "markdown");
}}
onCopyHtml={async () => {
setOpenExportFor(null);
await runExport(entry.item, "html");
}}
/>
</li>
</Fragment>
);
})}
{pendingInsertIndex === sortedItems.length && (
<li>
<InlineCaptureForm />
</li>
)}
</ul>
{toast && (
<div
role="status"
aria-live="polite"
data-testid="export-toast"
data-tone={toast.tone}
style={{
position: "absolute",
left: 12,
right: 12,
bottom: 12,
padding: 8,
fontSize: 12,
background: toast.tone === "success" ? "#d6f0d6" : "#f9d6d6",
color: toast.tone === "success" ? "#0a5a0a" : "#7a0000",
border: `1px solid ${toast.tone === "success" ? "#0a5a0a" : "#7a0000"}`,
borderRadius: 3,
}}
>
{toast.message}
</div>
)}
</aside>
);
}
interface EvidenceCardProps {
readonly item: EvidenceItem;
readonly annotation: Annotation | null;
readonly isActive: boolean;
readonly isExportOpen: boolean;
readonly isEditing: boolean;
readonly editQuote: string;
readonly editCommentary: string;
onActivate(): void;
onBeginEdit(): void;
onChangeQuote(next: string): void;
onChangeCommentary(next: string): void;
onSaveEdit(): void;
onCancelEdit(): void;
onToggleExport(): void;
onCopyMarkdown(): Promise<void>;
onCopyHtml(): Promise<void>;
}
function EvidenceCard(p: EvidenceCardProps) {
const quote = p.annotation?.quote ?? "(no quote)";
return (
<div
data-testid={`evidence-card-${p.item.id}`}
data-active={p.isActive ? "true" : "false"}
style={{
position: "relative",
background: "#fff8d6",
border: p.isActive ? "3px solid #b78b1c" : "1px solid #e0c050",
borderRadius: 2,
}}
>
{p.isEditing ? (
<EvidenceFormBody
quote={p.editQuote}
commentary={p.editCommentary}
onChangeQuote={p.onChangeQuote}
onChangeCommentary={p.onChangeCommentary}
onSave={p.onSaveEdit}
onCancel={p.onCancelEdit}
saveLabel="Save"
cancelLabel="Cancel"
helper="The marked passage in the document stays the same."
testidPrefix={`evidence-edit-${p.item.id}`}
/>
) : (
<>
<button
type="button"
onClick={p.onActivate}
aria-current={p.isActive ? "true" : undefined}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
paddingRight: 96,
cursor: "pointer",
fontSize: 12,
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</div>
{p.item.commentary && (
<div style={{ color: "#333", marginBottom: 4 }}>
{p.item.commentary}
</div>
)}
<div style={{ color: "#666", fontSize: 11 }}>
status: {p.item.status}
</div>
</button>
<div
style={{
position: "absolute",
top: 6,
right: 6,
display: "flex",
gap: 4,
}}
>
<button
type="button"
aria-label="Edit citation and commentary"
data-testid={`evidence-edit-toggle-${p.item.id}`}
onClick={(e) => {
e.stopPropagation();
p.onBeginEdit();
}}
title="Edit citation and commentary"
style={iconButtonStyle}
>
</button>
<button
type="button"
aria-haspopup="menu"
aria-expanded={p.isExportOpen}
aria-label="Export evidence item"
data-testid={`export-toggle-${p.item.id}`}
onClick={(e) => {
e.stopPropagation();
p.onToggleExport();
}}
style={iconButtonStyle}
>
Export
</button>
</div>
{p.isExportOpen && (
<div
role="menu"
data-testid={`export-menu-${p.item.id}`}
style={{
position: "absolute",
top: 28,
right: 6,
zIndex: 10,
background: "white",
border: "1px solid #888",
borderRadius: 3,
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
padding: 4,
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 160,
}}
>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
await p.onCopyMarkdown();
}}
style={menuButtonStyle}
>
Copy as Markdown
</button>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
await p.onCopyHtml();
}}
style={menuButtonStyle}
>
Copy as HTML
</button>
</div>
)}
</>
)}
</div>
);
}
const iconButtonStyle: CSSProperties = {
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
lineHeight: 1,
};
const menuButtonStyle: CSSProperties = {
textAlign: "left",
background: "transparent",
border: "none",
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
};

View file

@ -0,0 +1,213 @@
/**
* InlineCaptureForm capture-flow tests (CWORK-WP-0001 T04).
*
* Covers the selection evidence pipeline without mounting the heavy
* PDF viewer:
* - a pending selection seeds the form's citation text,
* - Save mints an annotation + evidence item on the real engine and
* clears the pending selection,
* - Discard clears the pending selection without creating evidence,
* - switching the active document resets the pending selection (the
* capture belonged to the previous document's viewer state).
*
* Uses a real engine so the anchor `createSelectors` + engine services
* are exercised end-to-end.
*/
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEngine, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import { newId } from "@shared/ids";
import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor";
import { EngineProvider, InlineCaptureForm } from "./index";
import { useActiveDocumentId, usePendingSelection } from "./EngineContext";
const NOW = "2026-05-25T00:00:00.000Z";
function seedEngine(): { engine: Engine; documentId: DocumentId } {
const engine = createEngine();
const documentId = newId("document") as DocumentId;
engine.documents.register({
document: {
id: documentId,
title: "Contract",
mediaType: "application/pdf",
fingerprint: "fp-contract",
createdAt: NOW,
updatedAt: NOW,
},
representation: {
id: newId("representation"),
documentId,
representationType: "pdf-text",
contentHash: "fp-contract",
canonicalText: "Die Frist endet am 31. März 2024.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 34, pageLength: 34 }],
generatedAt: NOW,
},
});
// A second document to switch to (exercises the pending reset).
const otherId = newId("document") as DocumentId;
engine.documents.register({
document: {
id: otherId,
title: "Appendix",
mediaType: "application/pdf",
fingerprint: "fp-appendix",
createdAt: NOW,
updatedAt: NOW,
},
representation: {
id: newId("representation"),
documentId: otherId,
representationType: "pdf-text",
contentHash: "fp-appendix",
canonicalText: "Appendix body.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 14, pageLength: 14 }],
generatedAt: NOW,
},
});
return { engine, documentId };
}
const CAPTURE: PdfSelectionCapture = {
kind: "pdf",
text: "Die Frist endet am 31. März 2024.",
page: 1,
rects: [{ x: 0.1, y: 0.1, width: 0.5, height: 0.02 }],
};
/** Test controls: activate a document + arm/switch the pending selection. */
function Controls({
documentId,
otherLabel,
}: {
readonly documentId: DocumentId;
readonly otherLabel: string;
}) {
const { id, setId } = useActiveDocumentId();
const { set } = usePendingSelection();
return (
<div>
<button data-testid="activate" onClick={() => setId(documentId)}>
activate
</button>
<button
data-testid="arm-pending"
onClick={() => set({ capture: CAPTURE, selectors: [] })}
>
arm
</button>
<button
data-testid="switch-doc"
onClick={() => {
const other = otherLabel as DocumentId;
setId(other);
}}
>
switch
</button>
<span data-testid="active-id">{id ?? "none"}</span>
</div>
);
}
function Harness({ engine, documentId, otherId }: {
readonly engine: Engine;
readonly documentId: DocumentId;
readonly otherId: DocumentId;
}) {
return (
<EngineProvider engine={engine}>
<Controls documentId={documentId} otherLabel={otherId} />
<InlineCaptureForm />
</EngineProvider>
);
}
beforeEach(() => {
globalThis.localStorage?.clear();
});
afterEach(() => {
cleanup();
});
describe("InlineCaptureForm capture flow", () => {
it("seeds the form from the pending selection and saves evidence", async () => {
const { engine, documentId } = seedEngine();
const otherId = engine.documents.list().find((d) => d.id !== documentId)!.id;
const user = userEvent.setup();
render(<Harness engine={engine} documentId={documentId} otherId={otherId} />);
await user.click(screen.getByTestId("activate"));
await user.click(screen.getByTestId("arm-pending"));
const quote = await screen.findByTestId("inline-capture-quote");
expect((quote as HTMLTextAreaElement).value).toBe(
"Die Frist endet am 31. März 2024.",
);
await user.type(
screen.getByTestId("inline-capture-commentary"),
"Deadline clause",
);
await user.click(screen.getByTestId("inline-capture-save"));
await waitFor(() => {
expect(screen.queryByTestId("inline-capture-form")).toBeNull();
});
// One annotation + one evidence item were minted on the real engine.
expect(engine.annotations.listByDocument(documentId)).toHaveLength(1);
expect(engine.evidence.listByDocument(documentId)).toHaveLength(1);
expect(engine.evidence.listByDocument(documentId)[0]!.commentary).toBe("Deadline clause");
});
it("discards the pending selection without creating evidence", async () => {
const { engine, documentId } = seedEngine();
const otherId = engine.documents.list().find((d) => d.id !== documentId)!.id;
const user = userEvent.setup();
render(<Harness engine={engine} documentId={documentId} otherId={otherId} />);
await user.click(screen.getByTestId("activate"));
await user.click(screen.getByTestId("arm-pending"));
await screen.findByTestId("inline-capture-form");
await user.click(screen.getByTestId("inline-capture-cancel"));
await waitFor(() => {
expect(screen.queryByTestId("inline-capture-form")).toBeNull();
});
expect(engine.evidence.listByDocument(documentId)).toHaveLength(0);
});
it("resets the pending selection when the active document changes", async () => {
const { engine, documentId } = seedEngine();
const otherId = engine.documents.list().find((d) => d.id !== documentId)!.id;
const user = userEvent.setup();
render(<Harness engine={engine} documentId={documentId} otherId={otherId} />);
await user.click(screen.getByTestId("activate"));
await user.click(screen.getByTestId("arm-pending"));
await screen.findByTestId("inline-capture-form");
// Switching documents drops the pending capture from the previous doc.
await user.click(screen.getByTestId("switch-doc"));
await waitFor(() => {
expect(screen.queryByTestId("inline-capture-form")).toBeNull();
});
expect(engine.evidence.listByDocument(documentId)).toHaveLength(0);
});
});

View file

@ -0,0 +1,98 @@
/**
* `InlineCaptureForm` the "I just selected text, let me save it as
* evidence" form. Renders only when a `pendingSelection` is set;
* `EvidenceSidebar` slots it into the right position in document
* order so the new capture appears between the cards that bracket
* it.
*
* Uses the shared `EvidenceFormBody` so the field layout matches the
* edit form on an existing card. The user can refine the
* auto-captured citation text before saving (handy when the
* underlying text layer captured fragments they can paste in the
* correct quote without re-selecting).
*
* Save pipeline:
* 1. `createSelectors(capture, representation)` anchor builds the
* maximal selector set against the active representation.
* 2. `engine.annotations.create(...)` engine mints an Annotation +
* emits AnnotationCreated.
* 3. `engine.evidence.create(...)` engine mints the EvidenceItem
* with the user's commentary, emits EvidenceItemCreated.
*/
import { useEffect, useState } from "react";
import { createSelectors } from "@citation-evidence/evidence-anchor";
import {
useActiveDocument,
useEngine,
usePendingSelection,
} from "./EngineContext";
import { EvidenceFormBody } from "./EvidenceFormBody";
export function InlineCaptureForm() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { pending, set } = usePendingSelection();
const [quote, setQuote] = useState("");
const [commentary, setCommentary] = useState("");
// Re-seed the form whenever a fresh selection arrives.
useEffect(() => {
setQuote(pending?.capture.text ?? "");
setCommentary("");
}, [pending]);
if (!pending || !document || !representation) return null;
const handleSave = () => {
const selectors = createSelectors(pending.capture, representation);
const annotation = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors,
quote: quote.trim().length > 0 ? quote : pending.capture.text,
});
engine.evidence.create({
annotationIds: [annotation.id],
...(commentary.trim().length > 0 ? { commentary: commentary.trim() } : {}),
});
set(null);
};
const handleDiscard = () => set(null);
const selectorCount = pending.selectors.length;
return (
<div
data-testid="inline-capture-form"
style={{
border: "1px dashed #b78b1c",
background: "#fff8d6",
marginBottom: 8,
borderRadius: 2,
}}
>
<EvidenceFormBody
quote={quote}
commentary={commentary}
onChangeQuote={setQuote}
onChangeCommentary={setCommentary}
onSave={handleSave}
onCancel={handleDiscard}
saveLabel="Save evidence"
cancelLabel="Discard"
badge={
<>
New evidence (
{selectorCount} selector{selectorCount === 1 ? "" : "s"}
) refine the citation if needed
</>
}
testidPrefix="inline-capture"
/>
</div>
);
}

18
src/work/README.md Normal file
View file

@ -0,0 +1,18 @@
# `src/work/` — review workspace package surface
This directory is the extracted home of the review workflow UI:
collection pane, viewer shell, evidence sidebar, and annotation capture flow.
It is published as the `@citation-evidence/work` package (barrel: `index.ts`)
and consumed by the `citation-evidence` umbrella app's review mode.
Extraction completed under `CWORK-WP-0001`; the umbrella app no longer carries a
local `src/work/` copy.
Imports from `work/` may depend on:
- `@shared/*`, `@engine/*` (from `@citation-evidence/engine`)
- `@citation-evidence/evidence-anchor`
- `@source/*` (PDF ingestion + local `viewer-url` policy)
They may not import from binder- or umbrella-app-owned code. These edges are
enforced by `eslint-plugin-boundaries`.

56
src/work/ReviewShell.tsx Normal file
View file

@ -0,0 +1,56 @@
/**
* ReviewShell the repo-owned three-pane review layout.
*
*
* Collection Document Viewer Evidence
* List Sidebar
*
*
* This is the extracted successor to the umbrella app's `ReviewLayout`
* (CWORK-WP-0001 T03/T05). The umbrella app now composes review mode by
* mounting `<ReviewShell>` inside its `SessionProvider`/`EngineProvider`
* rather than owning the pane layout itself.
*
* Upload seam (CWORK-WP-0001 T03 decision): the left pane's upload
* affordance is a slot. When a consumer passes `upload`, that node is
* used; otherwise the package's own `UploadDropzone` is rendered, so the
* shell is fully functional without any umbrella-local imports.
*/
import type { ReactNode } from "react";
import { CollectionList } from "./CollectionList";
import { EvidenceSidebar } from "./EvidenceSidebar";
import { UploadDropzone } from "./UploadDropzone";
import { ViewerShell } from "./ViewerShell";
import { useActiveSession } from "./SessionContext";
export interface ReviewShellProps {
/**
* Optional upload affordance rendered above the collection list. When
* omitted, the package's default `UploadDropzone` is used.
*/
readonly upload?: ReactNode;
/** Optional collection-pane title; defaults to the active session name. */
readonly title?: string;
}
export function ReviewShell({ upload, title }: ReviewShellProps) {
const session = useActiveSession();
return (
<div
style={{
display: "flex",
height: "100%",
fontFamily: "system-ui, sans-serif",
}}
>
<CollectionList
upload={upload ?? <UploadDropzone />}
title={title ?? session?.name ?? "Collection"}
/>
<ViewerShell />
<EvidenceSidebar />
</div>
);
}

241
src/work/SessionContext.tsx Normal file
View file

@ -0,0 +1,241 @@
/**
* SessionProvider owns the cross-session services.
*
* Layers above the per-session `EngineProvider`. Responsibilities:
*
* - hold the `SessionService` + its own bus instance
* - hydrate sessions from `localStorage` on first mount
* - expose `useSessionService()`, `useActiveSession()`, hooks to
* subscribe to session bus events
*
* Switching sessions is a side effect of calling
* `useSessionService().setActive(...)`. The hook tracks the active id
* via the bus's `SessionActivated` event so the value stays a single
* source of truth.
*
* NB: this module does *not* mount the `EngineProvider`. T04 wires the
* top-level App so the EngineProvider is keyed by the active session id
* (`<EngineProvider key={activeId} sessionId={activeId} />`). Keeping
* the two providers separate lets tests target one without the other.
*/
import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import {
attachSessionPersister,
createEventBus,
createInMemorySessionRepository,
createSessionService,
restoreSessionsFromStorage,
type EventBus,
type SessionService,
} from "@engine/index";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import {
SessionInternalContext,
type SessionInternalContextValue,
} from "./SessionContextInternal";
const SessionContext = SessionInternalContext;
type SessionContextValue = SessionInternalContextValue;
interface SessionProviderProps {
readonly children: ReactNode;
/** Inject a pre-built service for tests; production uses the default. */
readonly service?: SessionService;
readonly bus?: EventBus;
}
export function SessionProvider({
children,
service: injectedService,
bus: injectedBus,
}: SessionProviderProps) {
const bus = useMemo(() => injectedBus ?? createEventBus(), [injectedBus]);
const [repo] = useState(() => createInMemorySessionRepository());
const service = useMemo(
() => injectedService ?? createSessionService(repo, bus),
[injectedService, repo, bus],
);
const [activeId, setActiveId] = useState<SessionId | null>(null);
const [hydrated, setHydrated] = useState<boolean>(false);
const [byteStores] = useState<Map<SessionId, PdfByteStore>>(() => new Map());
const [sessionVersions, setSessionVersions] = useState<ReadonlyMap<SessionId, number>>(
() => new Map(),
);
const getOrCreateByteStore = useCallback(
(sessionId: SessionId) => {
let store = byteStores.get(sessionId);
if (!store) {
store = createPdfByteStore();
byteStores.set(sessionId, store);
}
return store;
},
[byteStores],
);
const getSessionVersion = useCallback(
(sessionId: SessionId) => sessionVersions.get(sessionId) ?? 0,
[sessionVersions],
);
const bumpSessionVersion = useCallback((sessionId: SessionId) => {
setSessionVersions((prev) => {
const next = new Map(prev);
next.set(sessionId, (prev.get(sessionId) ?? 0) + 1);
return next;
});
}, []);
// Hydrate from storage, then attach the persister.
useEffect(() => {
if (injectedService) {
setHydrated(true);
return;
}
if (typeof globalThis.localStorage === "undefined") {
setHydrated(true);
return;
}
const result = restoreSessionsFromStorage(repo, service);
if (result.restored && result.activeSessionId) {
setActiveId(result.activeSessionId);
}
setHydrated(true);
return attachSessionPersister(service, bus);
}, [bus, injectedService, repo, service]);
// Keep the active-id mirror in sync with the bus.
useEffect(() => {
return bus.on("SessionActivated", (e) => {
setActiveId(e.sessionId);
});
}, [bus]);
// Drop byte stores for sessions that get deleted (revoking blob URLs).
useEffect(() => {
return bus.on("SessionDeleted", (e) => {
const store = byteStores.get(e.sessionId);
if (store) {
store.clear();
byteStores.delete(e.sessionId);
}
});
}, [bus, byteStores]);
const value = useMemo<SessionContextValue>(
() => ({
service,
bus,
activeId,
hydrated,
getOrCreateByteStore,
getSessionVersion,
bumpSessionVersion,
}),
[
service,
bus,
activeId,
hydrated,
getOrCreateByteStore,
getSessionVersion,
bumpSessionVersion,
],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSessionService(): SessionService {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionService: missing SessionProvider");
return ctx.service;
}
export function useSessionBus(): EventBus {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionBus: missing SessionProvider");
return ctx.bus;
}
export function useActiveSessionId(): SessionId | null {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useActiveSessionId: missing SessionProvider");
return ctx.activeId;
}
export function useActiveSession(): Session | null {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useActiveSession: missing SessionProvider");
return ctx.activeId ? ctx.service.get(ctx.activeId) : null;
}
export function useSessionsHydrated(): boolean {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionsHydrated: missing SessionProvider");
return ctx.hydrated;
}
export function useSessionByteStore(sessionId: SessionId): PdfByteStore {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionByteStore: missing SessionProvider");
return ctx.getOrCreateByteStore(sessionId);
}
export function useSessionVersionBumper(): (sessionId: SessionId) => void {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionVersionBumper: missing SessionProvider");
return ctx.bumpSessionVersion;
}
export function useSessionVersion(sessionId: SessionId): number {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionVersion: missing SessionProvider");
return ctx.getSessionVersion(sessionId);
}
export function useSessionByteStoreRegistry(): {
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
} {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionByteStoreRegistry: missing SessionProvider");
return { getOrCreateByteStore: ctx.getOrCreateByteStore };
}
/**
* Re-render whenever the session list mutates. Returns a tick counter
* that callers can use as a `useMemo`/`useEffect` dependency to read
* `service.list()` lazily.
*/
export function useSessionListTick(): number {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionListTick: missing SessionProvider");
const [tick, setTick] = useState(0);
useEffect(() => {
const bump = () => setTick((t) => t + 1);
const offs = [
ctx.bus.on("SessionCreated", bump),
ctx.bus.on("SessionRenamed", bump),
ctx.bus.on("SessionDeleted", bump),
];
return () => {
for (const off of offs) off();
};
}, [ctx.bus]);
return tick;
}

View file

@ -0,0 +1,27 @@
/**
* Internal: the React Context object backing `SessionProvider`.
*
* Lives in its own module so `EngineContext.tsx` can subscribe without
* importing the full `SessionContext.tsx` (which would re-export the
* EngineProvider via the same `@work` barrel and create a circular
* dependency at module-init time).
*/
import { createContext } from "react";
import type { SessionId } from "@shared/ids";
import type { EventBus, SessionService } from "@engine/index";
import type { PdfByteStore } from "@source/index";
export interface SessionInternalContextValue {
readonly service: SessionService;
readonly bus: EventBus;
readonly activeId: SessionId | null;
readonly hydrated: boolean;
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
getSessionVersion(sessionId: SessionId): number;
bumpSessionVersion(sessionId: SessionId): void;
}
export const SessionInternalContext = createContext<SessionInternalContextValue | null>(null);

198
src/work/UploadDropzone.tsx Normal file
View file

@ -0,0 +1,198 @@
/**
* UploadDropzone drag-drop + file-picker for uploading PDFs into the
* active session.
*
* On every successful drop:
* 1. read each File as bytes,
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
* via the session's `PdfByteStore`),
* 3. register the resulting `{document, representation}` with the
* engine,
* 4. activate the most-recently-uploaded document.
*
* Failures (non-PDFs, ingest errors) are surfaced inline above the
* dropzone; the caller doesn't need a separate toast for them.
*
* Extraction decision (CWORK-WP-0001 T03)
* This component lives in `citation-work` rather than the umbrella app.
* Its only dependencies are `@source` (PDF ingestion) and the local
* engine/session hooks both inside the review-workspace boundary so
* hosting it here lets the package render a complete review shell,
* including the upload affordance, without any umbrella-local imports.
* `ReviewShell` still exposes an `upload` slot so a consumer may swap in
* its own affordance; when the slot is omitted, this default is used.
*/
import { useCallback, useRef, useState } from "react";
import { ingestPdfFromFile } from "@source/index";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "./EngineContext";
interface UploadEntry {
readonly file: File;
status: "queued" | "uploading" | "done" | "error";
error?: string;
}
export interface UploadDropzoneProps {
/** Optional callback fired after each successful upload. */
readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void;
}
export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { setId } = useActiveDocumentId();
const [entries, setEntries] = useState<readonly UploadEntry[]>([]);
const [isOver, setIsOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const processFiles = useCallback(
async (files: readonly File[]) => {
if (files.length === 0) return;
const initial: UploadEntry[] = files.map((file) => {
const isPdf =
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
if (isPdf) return { file, status: "queued" };
return {
file,
status: "error",
error: "Not a PDF (only application/pdf accepted)",
};
});
setEntries((prev) => [...prev, ...initial]);
let lastDocumentId: import("@shared/ids").DocumentId | null = null;
for (const entry of initial) {
if (entry.status === "error") continue;
entry.status = "uploading";
setEntries((prev) => [...prev]);
try {
const { document, representation } = await ingestPdfFromFile(
entry.file,
byteStore,
);
engine.documents.register({ document, representation });
entry.status = "done";
lastDocumentId = document.id;
onUploaded?.(document.id);
} catch (err) {
entry.status = "error";
entry.error = err instanceof Error ? err.message : String(err);
}
setEntries((prev) => [...prev]);
}
if (lastDocumentId) setId(lastDocumentId);
},
[byteStore, engine, onUploaded, setId],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsOver(false);
const files = Array.from(e.dataTransfer.files);
void processFiles(files);
},
[processFiles],
);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsOver(true);
}, []);
const onDragLeave = useCallback(() => {
setIsOver(false);
}, []);
const openPicker = useCallback(() => {
fileInputRef.current?.click();
}, []);
const onPicked = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
void processFiles(files);
// Reset so the same filename can be picked again.
e.target.value = "";
},
[processFiles],
);
return (
<div data-testid="upload-dropzone">
<div
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
role="region"
aria-label="PDF upload"
style={{
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
background: isOver ? "#e8f0ff" : "#fafafa",
padding: 16,
textAlign: "center",
fontSize: 12,
color: "#555",
borderRadius: 4,
}}
>
<div>Drop PDF files here</div>
<div style={{ margin: "6px 0", color: "#888" }}>or</div>
<button
type="button"
onClick={openPicker}
data-testid="upload-pick-button"
style={{
fontSize: 12,
padding: "4px 10px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
}}
>
Choose PDF
</button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
multiple
onChange={onPicked}
style={{ display: "none" }}
data-testid="upload-file-input"
/>
</div>
{entries.length > 0 && (
<ul
data-testid="upload-progress"
style={{ listStyle: "none", padding: 0, margin: "8px 0 0", fontSize: 11 }}
>
{entries.map((entry, i) => (
<li
key={`${entry.file.name}-${i}`}
data-status={entry.status}
style={{
padding: "2px 4px",
color:
entry.status === "error"
? "#7a0000"
: entry.status === "done"
? "#0a5a0a"
: "#333",
}}
>
{entry.file.name} {entry.status}
{entry.error ? `: ${entry.error}` : ""}
</li>
))}
</ul>
)}
</div>
);
}

147
src/work/ViewerShell.tsx Normal file
View file

@ -0,0 +1,147 @@
/**
* ViewerShell the centre pane.
*
* Hosts the viewer adapter (currently the T02 PDF spike) and shows whatever
* is active. `work/` consumes only the adapter's public surface
* (`PdfSpikeViewer`) it never touches PDF.js or react-pdf-highlighter-plus
* directly. When the PDF library is swapped (or the spike is replaced),
* only the adapter module changes; this shell stays the same.
*
* The annotation toolbar lived here in earlier iterations; CE-WP-0005-iter4
* moved it into the evidence sidebar so the capture form appears in the
* sidebar's document-flow position. The viewer now only renders the PDF
* and surfaces the activate/click events.
*/
import { useCallback, useMemo } from "react";
import { PdfSpikeViewer, type StoredAnnotation } from "@citation-evidence/evidence-anchor";
import { resolvePdfViewerUrl } from "@source/pdf/viewer-url";
import type { AnnotationId } from "@shared/ids";
import {
useActiveDocument,
useEngine,
useEngineEventTick,
useLastActivatedEvidence,
usePendingSelection,
usePdfByteStore,
useScrollToAnnotation,
} from "./EngineContext";
import { useDebugFlag } from "./useDebugFlags";
export function ViewerShell() {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { document, representation } = useActiveDocument();
const { set: setPending } = usePendingSelection();
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
const [debugTextLayer] = useDebugFlag("textLayer");
const [hideCanvas] = useDebugFlag("hideCanvas");
const [hideTextLayer] = useDebugFlag("hideTextLayer");
const [hideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
const [hideXfaLayer] = useDebugFlag("hideXfaLayer");
const activeEvidenceId = useLastActivatedEvidence();
// The viewer needs to re-fetch its highlight list whenever annotations
// change. The tick is included in the memo deps so the list re-resolves.
const annotationTick = useEngineEventTick("AnnotationCreated");
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
const annotations = useMemo<StoredAnnotation[]>(() => {
if (!document) return [];
return engine.annotations.listByDocument(document.id).map((a) => ({
id: a.id,
text: a.quote ?? "",
selectors: a.selectors,
}));
}, [document, engine, annotationTick, annotationUpdateTick]);
// The annotation id that visually represents the "active" focus —
// derived from the active evidence's first annotation.
const activeAnnotationId = useMemo<AnnotationId | null>(() => {
if (!activeEvidenceId) return null;
const item = engine.evidence.get(activeEvidenceId);
return item?.annotationIds[0] ?? null;
}, [activeEvidenceId, engine]);
const fileUrl = useMemo(() => {
if (!document) return null;
return resolvePdfViewerUrl(document, byteStore);
}, [document, byteStore]);
const scrollRequestKey =
scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null;
const handleHighlightClicked = useCallback(
(annotationId: string) => {
if (!document) return;
const item = engine.evidence.findByAnnotationId(
document.id,
annotationId as AnnotationId,
);
if (!item) return;
engine.evidence.activate(item.id, "citation-card");
// Re-trigger scroll so a click on the highlight also keeps it
// centred in the viewport.
scrollTo(annotationId as AnnotationId);
},
[document, engine, scrollTo],
);
if (!document || !representation || !fileUrl) {
return (
<main
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#666",
fontFamily: "system-ui, sans-serif",
}}
>
Upload a PDF on the left to begin.
</main>
);
}
return (
<main
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
position: "relative",
}}
>
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer
// Re-key on document + debug flags only — scroll requests must
// not remount the viewer (that re-fetches the PDF blob).
key={[
document.id,
debugTextLayer ? "d" : "n",
hideCanvas ? "hc" : "",
hideTextLayer ? "ht" : "",
hideAnnotationLayer ? "ha" : "",
hideXfaLayer ? "hx" : "",
].join("#")}
pdfUrl={fileUrl}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
{...(scrollRequestKey ? { scrollRequestKey } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer}
hideCanvas={hideCanvas}
hideTextLayer={hideTextLayer}
hideAnnotationLayer={hideAnnotationLayer}
hideXfaLayer={hideXfaLayer}
onSelectionCaptured={(capture, selectors) => {
setPending({ capture, selectors });
}}
/>
</div>
</main>
);
}

45
src/work/index.ts Normal file
View file

@ -0,0 +1,45 @@
// Public surface of the review-workspace package.
//
// Extraction is incremental (CWORK-WP-0001): T02 lands the provider/hook
// foundation below; T03T05 append the collection pane, viewer/capture flow,
// and evidence sidebar exports.
export { ReviewShell, type ReviewShellProps } from "./ReviewShell";
export { CollectionList, type CollectionListProps } from "./CollectionList";
export { UploadDropzone, type UploadDropzoneProps } from "./UploadDropzone";
export { ViewerShell } from "./ViewerShell";
export { InlineCaptureForm } from "./InlineCaptureForm";
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
export {
useExportEvidence,
type ExportEvidenceApi,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
export { useDebugFlag, type DebugFlag } from "./useDebugFlags";
export {
EngineProvider,
useEngine,
useActiveDocument,
useActiveDocumentId,
useEngineEventTick,
useEngineRevision,
useLastActivatedEvidence,
usePdfByteStore,
usePendingSelection,
useScrollToAnnotation,
type PendingSelection,
} from "./EngineContext";
export {
SessionProvider,
useActiveSession,
useActiveSessionId,
useSessionBus,
useSessionByteStore,
useSessionByteStoreRegistry,
useSessionListTick,
useSessionService,
useSessionsHydrated,
useSessionVersion,
useSessionVersionBumper,
} from "./SessionContext";

60
src/work/useDebugFlags.ts Normal file
View file

@ -0,0 +1,60 @@
/**
* `useDebugFlags` read/write the small set of developer-facing
* toggles. Persisted in `localStorage` under
* `citation-evidence:debug:<flag>` so a reload preserves them.
*
* Used by the SessionMenu (to render a checkbox) and by the viewer
* shell (to decide whether to paint the PDF text layer + log
* selection events). Kept in `work/` so both the app layer (toggle UI)
* and the anchor consumer (viewer adapter prop) reach it via the
* existing boundary chain.
*/
import { useCallback, useEffect, useState } from "react";
const KEY_PREFIX = "citation-evidence:debug:";
const STORAGE_EVENT = "ce-debug-flag-change";
export type DebugFlag =
| "textLayer"
| "hideCanvas"
| "hideTextLayer"
| "hideAnnotationLayer"
| "hideXfaLayer";
function storageKey(flag: DebugFlag): string {
return `${KEY_PREFIX}${flag}`;
}
function read(flag: DebugFlag): boolean {
if (typeof localStorage === "undefined") return false;
return localStorage.getItem(storageKey(flag)) === "1";
}
export function useDebugFlag(flag: DebugFlag): readonly [boolean, (next: boolean) => void] {
const [value, setValue] = useState<boolean>(() => read(flag));
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ flag: DebugFlag }>).detail;
if (!detail || detail.flag !== flag) return;
setValue(read(flag));
};
window.addEventListener(STORAGE_EVENT, handler);
return () => window.removeEventListener(STORAGE_EVENT, handler);
}, [flag]);
const setter = useCallback(
(next: boolean) => {
if (typeof localStorage === "undefined") return;
if (next) localStorage.setItem(storageKey(flag), "1");
else localStorage.removeItem(storageKey(flag));
setValue(next);
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: { flag } }));
},
[flag],
);
return [value, setter] as const;
}

View file

@ -0,0 +1,89 @@
/**
* `useExportEvidence` wires engine + renderers + clipboard for the
* sidebar export affordance (CE-WP-0004-T04).
*
* Renderers live in `@engine/rendering`; the hook resolves the
* (`EvidenceItem`, `Document`, first `Annotation`) triple from the engine
* for a given item id, renders it, and writes the result to
* `navigator.clipboard`.
*
* Returns `{ ok: true }` on success and `{ ok: false, reason }` on any
* failure callers (the sidebar toast) decide what to render. Errors
* are swallowed at the boundary instead of throwing so a clipboard
* permission denial doesn't crash the UI tree.
*/
import { useCallback } from "react";
import {
renderCitationCardHtml,
renderCitationCardMarkdown,
} from "@engine/rendering";
import type { EvidenceItem } from "@shared/evidence";
import { useEngine } from "./EngineContext";
export type ExportFormat = "markdown" | "html";
export type ExportResult =
| { readonly ok: true; readonly format: ExportFormat; readonly content: string }
| {
readonly ok: false;
readonly reason:
| "no-annotation"
| "annotation-missing"
| "document-missing"
| "clipboard-unavailable"
| "clipboard-write-failed";
};
export interface ExportEvidenceApi {
/**
* Render the given item as `format` and write it to the clipboard.
* Resolves with the result; rejection never propagates.
*/
exportItem(item: EvidenceItem, format: ExportFormat): Promise<ExportResult>;
}
/**
* Inline export: build the content from item+document+annotation but skip
* the clipboard step. Useful for tests and for surfaces (toasts, future
* "Export as file" dialogs) that want the rendered string.
*/
export function useExportEvidence(): ExportEvidenceApi {
const engine = useEngine();
const exportItem = useCallback<ExportEvidenceApi["exportItem"]>(
async (item, format) => {
const annotationId = item.annotationIds[0];
if (!annotationId) return { ok: false, reason: "no-annotation" };
const annotation = engine.annotations.get(annotationId);
if (!annotation) return { ok: false, reason: "annotation-missing" };
const document = engine.documents.get(annotation.documentId);
if (!document) return { ok: false, reason: "document-missing" };
const content =
format === "markdown"
? renderCitationCardMarkdown({ evidenceItem: item, document, annotation })
: renderCitationCardHtml({ evidenceItem: item, document, annotation });
const clipboard =
typeof navigator !== "undefined" ? navigator.clipboard : undefined;
if (!clipboard || typeof clipboard.writeText !== "function") {
return { ok: false, reason: "clipboard-unavailable" };
}
try {
await clipboard.writeText(content);
return { ok: true, format, content };
} catch {
return { ok: false, reason: "clipboard-write-failed" };
}
},
[engine],
);
return { exportItem };
}

36
tsconfig.json Normal file
View file

@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["../citation-engine/src/shared/*"],
"@engine/*": ["../citation-engine/src/engine/*"],
"@source/*": ["../citation-evidence/src/source/*"],
"@work/*": ["src/work/*"]
}
},
"include": ["src", "tests", "vite.config.ts", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

49
vite.config.ts Normal file
View file

@ -0,0 +1,49 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: resolve(__dirname, "node_modules/pdfjs-dist/cmaps") + "/[!.]*",
dest: "cmaps",
},
{
src: resolve(__dirname, "node_modules/pdfjs-dist/standard_fonts") + "/[!.]*",
dest: "standard_fonts",
},
],
}),
],
resolve: {
alias: {
"@shared": resolve(__dirname, "../citation-engine/src/shared"),
"@engine": resolve(__dirname, "../citation-engine/src/engine"),
// Anchor is consumed as the standalone `@citation-evidence/evidence-anchor`
// package (resolved via node_modules). `@source` still points at the
// umbrella source façade because it owns the local `viewer-url` policy
// that is not part of the extracted `evidence-source` package.
"@source": resolve(__dirname, "../citation-evidence/src/source"),
"@work": resolve(__dirname, "src/work"),
},
},
server: {
fs: {
allow: [
resolve(__dirname),
resolve(__dirname, "../citation-engine"),
resolve(__dirname, "../citation-evidence"),
],
},
},
optimizeDeps: {
exclude: ["pdfjs-dist"],
},
});

10
vitest.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default defineConfig({
...viteConfig,
test: {
environmentMatchGlobs: [["**/*.dom.test.{ts,tsx}", "happy-dom"]],
globals: false,
},
});

View file

@ -0,0 +1,341 @@
---
id: CWORK-WP-0001
type: workplan
title: "Extract the existing review workspace slice into citation-work"
domain: infotech
repo: citation-work
status: finished
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-06-21"
updated: "2026-07-09"
state_hub_workstream_id: "5978b563-16b7-41c2-8add-3f122dc3a61a"
---
# CWORK-WP-0001 — Extract the Existing Review Workspace Slice
## Goal
Turn `citation-work` from an INTENT-only repo into the standalone home of the
already-existing review workspace slice from `citation-evidence`, without
expanding scope beyond that current slice.
The implementation source of truth on 2026-07-08 is:
- `../citation-evidence/src/work/`
- `../citation-evidence/src/app/ReviewLayout.tsx`
- `../citation-evidence/src/app/sessions/UploadDropzone.tsx`
This workplan is intentionally Ralph-friendly: bounded, file-driven, and
self-closing once the extraction, cutover, docs, verification, and sync are
complete.
## Scope Locks
- **In scope:** repo scaffold, package/tooling setup, extraction of the current
review workspace React surface, umbrella-app cutover, test porting, repo doc
refresh, and closeout sync.
- **Out of scope for this workplan:** new product features not already present
in the upstream slice, including collection search/filter, document review
status UX, evidence status/tagging/filtering, and any binder-owned workflows.
- **Boundary rule:** `citation-work` may depend on `citation-engine`,
`evidence-anchor`, and `evidence-source`, but not `evidence-binder`.
- **If extraction reveals missing engine contracts:** fix only the minimum
import/integration issue needed to complete extraction, or register sibling
follow-on work. Do not broaden this workplan into cross-repo feature design.
## Ralph Execution
Recommended invocation after review:
```text
/ralph-workplan workplans/CWORK-WP-0001-extract-review-workspace.md --max-iterations 12
```
Expected done condition:
- all tasks below are `done`,
- frontmatter status is updated to `finished`,
- State Hub task/workplan statuses and progress note are synced.
## Dependency Order
```text
T01 (repo scaffold + boundaries)
└─ T02 (providers/hooks/barrel)
├─ T03 (collection pane + workspace shell)
├─ T04 (viewer + capture flow)
└─ T05 (evidence sidebar + export flow)
└─ T06 (umbrella cutover + test port)
└─ T07 (repo docs + deferred-gap registration)
└─ T08 (verification + sync + closeout)
```
## T01 — Bootstrap the repo scaffold and dependency boundaries
```task
id: CWORK-WP-0001-T01
status: done
priority: high
state_hub_task_id: "a4fc2589-c922-4740-8817-8098698facbc"
```
**Description**: Create the minimal standalone package/tooling baseline so this
repo can host extracted code instead of only intent docs.
**Implement**:
- Add repo-local package/tooling files adapted from `../citation-evidence/`:
`package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`,
`eslint.config.js`, and initial `src/` layout.
- Configure aliases and lint boundaries so `citation-work` may import only from
`engine`, `anchor`, and `source`.
- Remove stale repo wording that still implies binder-owned form-binding layout
belongs here.
**Acceptance criteria**:
- `pnpm lint`, `pnpm typecheck`, and `pnpm test` can run in this repo once the
extracted files land.
- No local config permits imports from `binder`.
- README/AGENTS/SCOPE wording no longer contradicts the repo boundary.
**Deliverables**: runnable scaffolded repo with enforced dependency edges.
---
## T02 — Extract providers, hooks, and the public barrel
```task
id: CWORK-WP-0001-T02
status: done
priority: high
depends_on: [T01]
state_hub_task_id: "d2e4c9da-915c-49ea-af7d-d927d935b13a"
```
**Description**: Move the stateful review-workspace foundation out of the
umbrella repo first so the UI components have a stable local API.
**Source files**:
- `../citation-evidence/src/work/index.ts`
- `../citation-evidence/src/work/EngineContext.tsx`
- `../citation-evidence/src/work/SessionContext.tsx`
- `../citation-evidence/src/work/SessionContextInternal.ts`
- `../citation-evidence/src/work/useDebugFlags.ts`
**Acceptance criteria**:
- `citation-work` exports the same provider/hook surface currently consumed by
`citation-evidence/src/app/`.
- Session-scoped storage keys, pending-selection state, scroll-to-annotation
behavior, and byte-store registry semantics remain unchanged.
- No extracted provider imports from umbrella-app files.
**Deliverables**: working local provider layer plus barrel exports.
---
## T03 — Extract the collection pane and workspace shell
```task
id: CWORK-WP-0001-T03
status: done
priority: high
depends_on: [T02]
state_hub_task_id: "58e5be13-08e2-4cd9-a348-26abe0f717d9"
```
**Description**: Move the left-pane and review-shell composition into
`citation-work`, including the upload seam decision.
**Source files**:
- `../citation-evidence/src/work/CollectionList.tsx`
- `../citation-evidence/src/app/ReviewLayout.tsx`
- `../citation-evidence/src/app/sessions/UploadDropzone.tsx` if kept as part of
the review workspace surface
**Implement**:
- Make the three-pane review layout repo-owned.
- Decide whether `UploadDropzone` belongs in this repo or stays in the umbrella
app as a narrow slot-based integration seam; document the decision in-code.
- Port `CollectionList.dom.test.tsx`.
**Acceptance criteria**:
- The extracted package can render the review shell with collection list and
upload affordance without umbrella-local imports.
- The upload seam is explicit and stable.
- Collection list tests pass from this repo.
**Deliverables**: local review-shell component and collection-pane coverage.
---
## T04 — Extract the viewer shell and selection-capture flow
```task
id: CWORK-WP-0001-T04
status: done
priority: high
depends_on: [T02]
state_hub_task_id: "048efc6c-b744-4b68-8073-7bcfcfc0fc91"
```
**Description**: Move the center-pane review flow without changing the
viewer-adapter boundary.
**Source files**:
- `../citation-evidence/src/work/ViewerShell.tsx`
- `../citation-evidence/src/work/InlineCaptureForm.tsx`
- `../citation-evidence/src/work/EvidenceFormBody.tsx`
**Acceptance criteria**:
- `citation-work` talks to the viewer only through `anchor`/`source`
contracts, not PDF.js internals.
- Pending selection, save/discard behavior, and active-evidence scroll behavior
match the umbrella implementation.
- DOM coverage exists for capture create/save/discard flow and document-switch
pending-selection reset.
**Deliverables**: extracted viewer/capture components plus local tests.
---
## T05 — Extract the evidence sidebar and export flow
```task
id: CWORK-WP-0001-T05
status: done
priority: high
depends_on: [T04]
state_hub_task_id: "10bff27a-5c84-46e5-8e5b-dc5bea6d24ae"
```
**Description**: Move the right-pane evidence management UI and preserve the
current export/edit behaviors.
**Source files**:
- `../citation-evidence/src/work/EvidenceSidebar.tsx`
- `../citation-evidence/src/work/useExportEvidence.ts`
**Acceptance criteria**:
- Document-order sorting, inline edit, pending-capture insertion, and
Markdown/HTML clipboard export all behave as before.
- `Cmd/Ctrl+Shift+C` export still works for the active evidence item.
- `EvidenceSidebar.dom.test.tsx` is ported and extended to cover at least edit
and activation behavior in addition to export.
**Deliverables**: extracted evidence sidebar flow plus local regression tests.
---
## T06 — Cut the umbrella app over to the extracted package
```task
id: CWORK-WP-0001-T06
status: done
priority: high
depends_on: [T03, T05]
state_hub_task_id: "da66f601-0467-41dd-9a64-943b54f0a232"
```
**Description**: Rewire `citation-evidence` so review mode consumes
`citation-work` as an extracted subsystem rather than a local `src/work/`
folder.
**Implement**:
- Update app-side imports in `../citation-evidence/src/app/App.tsx`,
`../citation-evidence/src/app/ReviewLayout.tsx`,
`../citation-evidence/src/app/forms/`, and
`../citation-evidence/src/app/sessions/` as needed.
- Keep top-bar routing, empty-state session creation, and binder/forms
composition in the umbrella repo.
- Port or update app-side tests affected by the import change.
**Acceptance criteria**:
- Review mode still supports session switching, PDF upload, collection list,
viewer, evidence capture, and evidence export after the cutover.
- The umbrella repo no longer depends on a local `src/work/` implementation for
review mode.
**Deliverables**: externalized package consumption from the umbrella app.
---
## T07 — Refresh repo docs and register deferred follow-on gaps
```task
id: CWORK-WP-0001-T07
status: done
priority: medium
depends_on: [T06]
state_hub_task_id: "87211f5e-2e72-46ad-9f02-9e13ecbe9aa2"
```
**Description**: Finish the repo transition from placeholder to extracted
package and record what remains intentionally out of scope for this slice.
**Implement**:
- Update `README.md`, `SCOPE.md`, and any local orientation docs to describe the
extracted implementation instead of a future placeholder.
- Record deferred workspace gaps explicitly:
collection search/filter, document review status UX, evidence
status/tagging/filtering, and any other notable INTENT-vs-implementation gap.
- If helpful, name the next follow-on workplan slice, but do not expand this
workplan to include it.
**Acceptance criteria**:
- A new contributor can tell from repo docs what code now lives here and what
still remains for later work.
- Deferred items are written down clearly enough to seed a subsequent workplan
without rediscovery.
**Deliverables**: accurate repo docs plus explicit deferred-gap list.
---
## T08 — Verify, sync, and close the extraction slice
```task
id: CWORK-WP-0001-T08
status: done
priority: high
depends_on: [T07]
state_hub_task_id: "c094f3ad-1793-4a61-b32f-2d8108f07e4b"
```
**Description**: Close the workplan cleanly so Ralph can retire with HEUREKA
and the State Hub read model matches the file.
**Acceptance criteria**:
- Local verification passes: `pnpm lint`, `pnpm typecheck`, and `pnpm test`.
- Workplan tasks marked `done` in the file are also marked `done` in State Hub.
- `fix-consistency` runs cleanly and updates `.custodian-brief.md` as needed.
- A progress note is posted summarizing what was extracted and what was deferred.
- Frontmatter status is updated from `ready`/`active` to `finished`.
**Deliverables**: passing extracted repo, synced hub state, and clean closeout.
## Deferred After This Workplan
These are intentionally not part of the Ralph loop for `CWORK-WP-0001`:
- collection search/filter UX,
- document review-status interactions,
- evidence-item status/tagging/filtering UX,
- any binder-integrated review-to-form workflows,
- any new viewer/document-format features not already in the current upstream
`src/work/` slice.

View file

@ -1,276 +0,0 @@
---
id: CWORK-WP-0001
type: workplan
title: "Stand up citation-work and extract the review workspace from citation-evidence"
domain: infotech
repo: citation-work
status: ready
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-06-21"
updated: "2026-07-08"
state_hub_workstream_id: "5978b563-16b7-41c2-8add-3f122dc3a61a"
---
# CWORK-WP-0001 — Stand Up `citation-work`
Repo review on 2026-07-08 found that `citation-work` is still INTENT-only.
The implementation source of truth currently lives upstream in:
- `../citation-evidence/src/work/`
- `../citation-evidence/src/app/ReviewLayout.tsx`
- `../citation-evidence/src/app/sessions/UploadDropzone.tsx`
That upstream slice already covers:
- session-scoped engine/session providers and barrel exports,
- collection list and review-layout composition,
- PDF viewer shell and selection capture,
- evidence sidebar, inline edit, and Markdown/HTML export,
- DOM tests for collection and evidence-sidebar flows.
It does **not** yet cover several workflows promised by `INTENT.md` and the
umbrella PRD: collection search/filter, document review status UX, evidence
status/tags/filtering, and repo-local packaging/tooling/docs. This workplan
replaces the old placeholder with the concrete extraction and gap-closure work.
If T07/T08 reveal missing shared types or engine mutators, create sibling work
in `citation-engine` rather than storing shadow state locally in `citation-work`.
## Dependency Order
```text
T01 (repo bootstrap + boundary docs)
└─ T02 (providers + public API)
├─ T03 (workspace shell, collection list, upload seam)
└─ T04 (viewer shell + capture flow)
└─ T05 (evidence sidebar + export flow)
└─ T06 (umbrella app cutover + test port)
├─ T07 (collection search/filter + document review status)
└─ T08 (evidence status/tags/filtering + closeout)
```
## T01 — Bootstrap the repo as a real package
```task
id: CWORK-WP-0001-T01
status: todo
priority: high
state_hub_task_id: "a4fc2589-c922-4740-8817-8098698facbc"
```
Create the standalone repo scaffold that the placeholder never had.
- Add the package/tooling baseline adapted from `../citation-evidence/`:
`package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`,
`eslint.config.js`, and `src/`.
- Establish the allowed dependency edges only: `citation-engine`,
`evidence-anchor`, and `evidence-source`; enforce the "`citation-work` may
not import from `binder`" boundary with lint/config.
- Update repo docs so they match the actual boundary. In particular, remove the
stale README wording that implies a form-binding layout owned here even though
binder is explicitly out of scope.
Acceptance: the repo can run `pnpm lint`, `pnpm typecheck`, and `pnpm test`
locally before any large component move lands.
---
## T02 — Extract providers, hooks, and the public barrel
```task
id: CWORK-WP-0001-T02
status: todo
priority: high
depends_on: [T01]
state_hub_task_id: "d2e4c9da-915c-49ea-af7d-d927d935b13a"
```
Move the stateful review-workspace foundation out of the umbrella app:
- `../citation-evidence/src/work/index.ts`
- `../citation-evidence/src/work/EngineContext.tsx`
- `../citation-evidence/src/work/SessionContext.tsx`
- `../citation-evidence/src/work/SessionContextInternal.ts`
- `../citation-evidence/src/work/useDebugFlags.ts`
Preserve the current session-scoped localStorage keys, byte-store registry,
engine-event hooks, pending-selection state, and scroll-to-annotation contract.
Audit the barrel against current consumers in `citation-evidence/src/app/` so
the exported API is complete before deeper UI extraction starts.
Acceptance: the umbrella app can import the current provider/hook surface from
the new repo without adding any `app/` or `binder/` dependency back-edges.
---
## T03 — Extract the review workspace shell and collection pane
```task
id: CWORK-WP-0001-T03
status: todo
priority: high
depends_on: [T02]
state_hub_task_id: "58e5be13-08e2-4cd9-a348-26abe0f717d9"
```
Move the collection-side review UI into this repo:
- `../citation-evidence/src/work/CollectionList.tsx`
- `../citation-evidence/src/app/ReviewLayout.tsx` or an equivalent
repo-owned `ReviewWorkspace` composition component
Also settle the upload seam. `UploadDropzone.tsx` currently lives under
`citation-evidence/src/app/sessions/` but only depends on `source/` plus work
hooks. Move it here unless a clear app-only reason is documented; if it stays in
the umbrella app, freeze the slot contract explicitly in docs/tests.
Port `CollectionList.dom.test.tsx` and add a workspace-shell smoke test so the
three-pane layout remains repo-owned rather than app-owned glue.
Acceptance: `citation-work` can render the review shell and document list on
its own, with session title and upload affordance supplied through a stable API.
---
## T04 — Extract the viewer shell and selection-capture flow
```task
id: CWORK-WP-0001-T04
status: todo
priority: high
depends_on: [T02]
state_hub_task_id: "048efc6c-b744-4b68-8073-7bcfcfc0fc91"
```
Move the central document-review flow:
- `../citation-evidence/src/work/ViewerShell.tsx`
- `../citation-evidence/src/work/InlineCaptureForm.tsx`
- `../citation-evidence/src/work/EvidenceFormBody.tsx`
Keep the current adapter boundary intact: `citation-work` should talk to the
viewer through `@anchor`/`@source` contracts, not through PDF.js internals.
Preserve the pending-selection flow, active-evidence scroll behavior, and debug
flag hooks.
Add DOM coverage for:
- selection creates pending capture state,
- discard clears pending state,
- save creates annotation + evidence and clears the form,
- switching documents clears stale pending selection.
Acceptance: text selection in the extracted workspace still results in a saved
annotation/evidence pair without any direct dependency on umbrella-app code.
---
## T05 — Extract the evidence sidebar and export flow
```task
id: CWORK-WP-0001-T05
status: todo
priority: high
depends_on: [T04]
state_hub_task_id: "10bff27a-5c84-46e5-8e5b-dc5bea6d24ae"
```
Move the evidence-management UI:
- `../citation-evidence/src/work/EvidenceSidebar.tsx`
- `../citation-evidence/src/work/useExportEvidence.ts`
Preserve the existing behaviors:
- document-order sorting by selector position,
- inline quote/commentary edit,
- insertion of the pending capture form in document order,
- Markdown/HTML clipboard export,
- `Cmd/Ctrl+Shift+C` export for the active evidence item.
Port `EvidenceSidebar.dom.test.tsx` and extend it beyond export coverage to
exercise edit/save, activation-scroll, and capture-form ordering.
Acceptance: the extracted sidebar can activate, edit, and export evidence items
without importing from `binder` or umbrella-app helpers.
---
## T06 — Cut the umbrella app over to the extracted package
```task
id: CWORK-WP-0001-T06
status: todo
priority: high
depends_on: [T03, T05]
state_hub_task_id: "da66f601-0467-41dd-9a64-943b54f0a232"
```
Rewire `citation-evidence` so it consumes `citation-work` as an external
subsystem rather than the local `src/work/` folder.
- Update the umbrella app imports in `src/app/App.tsx`, `src/app/forms/`,
`src/app/sessions/`, and related tests to target the extracted package.
- Keep app-only concerns in the umbrella repo: top-bar routing, session menu,
empty-state creation flow, and binder/forms composition.
- Verify that review mode still supports session switching, PDF upload, review
layout rendering, and evidence capture after the cutover.
Acceptance: the umbrella app no longer relies on a local `src/work/` tree for
review mode, and review-mode tests still pass from the app side.
---
## T07 — Add collection search/filter and document review-status UX
```task
id: CWORK-WP-0001-T07
status: todo
priority: medium
depends_on: [T06]
state_hub_task_id: "87211f5e-2e72-46ad-9f02-9e13ecbe9aa2"
```
Close the first gap between `INTENT.md` and the current upstream slice:
- support searching/filtering documents within the collection view,
- surface and persist `Document.reviewStatus` using the canonical values from
`../citation-evidence/wiki/SharedContracts.md`,
- show enough status information in the collection pane to drive a real review
queue instead of a bare file list.
If the current engine API cannot store/update `reviewStatus`, track that as a
small sibling prerequisite in `citation-engine` rather than inventing local
component state here.
Acceptance: a reviewer can narrow the collection, mark document review state,
reload, and see the same state restored through the normal engine/session path.
---
## T08 — Add evidence status, tags, filters, and repo closeout
```task
id: CWORK-WP-0001-T08
status: todo
priority: medium
depends_on: [T06]
state_hub_task_id: "c094f3ad-1793-4a61-b32f-2d8108f07e4b"
```
Close the remaining MVP gaps for the review workspace and then retire the
"intent-only" posture of this repo.
- Add evidence-item status editing using the canonical `EvidenceItem.status`
values (`candidate`, `confirmed`, `rejected`, `needs-check`).
- Add evidence tags plus sidebar filtering by status/tag, matching the
organizing workflows described in `INTENT.md`.
- Update `SCOPE.md`, README, and any repo-local orientation docs so they
describe the extracted implementation rather than a future placeholder.
- Finish with full verification (`lint`, `typecheck`, `test`) and update this
workplan from `ready` to `active`/`finished` as implementation actually moves.
Acceptance: `citation-work` is no longer just a placeholder repo; it owns the
review workspace package, its organizing UX, and its documentation with passing
local verification.