feat: extract review-workspace slice into @citation-evidence/work (CWORK-WP-0001)
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:
parent
c51ef94812
commit
0cd2ca5d04
30 changed files with 7958 additions and 342 deletions
|
|
@ -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
5
.gitignore
vendored
|
|
@ -7,6 +7,11 @@ __pycache__/
|
|||
# C extensions
|
||||
*.so
|
||||
|
||||
# Node / TypeScript
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
README.md
82
README.md
|
|
@ -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)
|
||||
|
|
|
|||
96
SCOPE.md
96
SCOPE.md
|
|
@ -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
71
eslint.config.js
Normal 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
60
package.json
Normal 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
4606
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
112
src/work/CollectionList.dom.test.tsx
Normal file
112
src/work/CollectionList.dom.test.tsx
Normal 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
166
src/work/CollectionList.tsx
Normal 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
316
src/work/EngineContext.tsx
Normal 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;
|
||||
}
|
||||
104
src/work/EvidenceFormBody.tsx
Normal file
104
src/work/EvidenceFormBody.tsx
Normal 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,
|
||||
};
|
||||
248
src/work/EvidenceSidebar.dom.test.tsx
Normal file
248
src/work/EvidenceSidebar.dom.test.tsx
Normal 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;
|
||||
526
src/work/EvidenceSidebar.tsx
Normal file
526
src/work/EvidenceSidebar.tsx
Normal 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 }}>
|
||||
“{quote.slice(0, 140)}
|
||||
{quote.length > 140 ? "…" : ""}”
|
||||
</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,
|
||||
};
|
||||
213
src/work/InlineCaptureForm.dom.test.tsx
Normal file
213
src/work/InlineCaptureForm.dom.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
98
src/work/InlineCaptureForm.tsx
Normal file
98
src/work/InlineCaptureForm.tsx
Normal 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
18
src/work/README.md
Normal 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
56
src/work/ReviewShell.tsx
Normal 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
241
src/work/SessionContext.tsx
Normal 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;
|
||||
}
|
||||
27
src/work/SessionContextInternal.ts
Normal file
27
src/work/SessionContextInternal.ts
Normal 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
198
src/work/UploadDropzone.tsx
Normal 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
147
src/work/ViewerShell.tsx
Normal 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
45
src/work/index.ts
Normal 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; T03–T05 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
60
src/work/useDebugFlags.ts
Normal 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;
|
||||
}
|
||||
89
src/work/useExportEvidence.ts
Normal file
89
src/work/useExportEvidence.ts
Normal 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
36
tsconfig.json
Normal 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
49
vite.config.ts
Normal 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
10
vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
341
workplans/CWORK-WP-0001-extract-review-workspace.md
Normal file
341
workplans/CWORK-WP-0001-extract-review-workspace.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue