Compare commits

...

2 Commits

Author SHA1 Message Date
779ae0d317 Implement CE-WP-0005 T01-T08: demo app — sessions, uploads, ZIP archive
Turn the MVP into a self-contained demo. Users now:
  1. Land on an empty-state and create a named session.
  2. Drag-drop or pick arbitrary PDFs into that session.
  3. Annotate, build evidence, link to form fields — all session-scoped.
  4. Export the whole session as a single .zip archive (manifest +
     per-document PDFs).
  5. Import a .zip back — into a new session, or merged into an
     existing one (documents deduped by SHA-256 fingerprint;
     annotations/evidence/links added additively).

Architecture:
- New shared types: SessionId, Session, SessionArchiveManifest +
  parseSessionArchiveManifest with schema-version validation.
- SessionService (engine/services/sessions.ts) handles lifecycle
  (create/rename/delete/setActive) + emits 4 new events through its
  own bus; SharedContracts.md §4 lists the additions.
- SessionProvider (work/SessionContext.tsx) owns the cross-session
  state: service, per-session PdfByteStore registry, per-session
  version counter that drives EngineProvider remounts after imports.
- EngineProvider becomes session-aware (sessionId prop drives per-
  session localStorage keys). Bumping engineRevision after
  restoreFromStorage forces consumers to re-render so restored repos
  show up immediately.
- PdfByteStore (source/pdf/byte-store.ts) holds Uint8Array bytes per
  document and mints blob URLs; ingestPdfFromFile is the upload
  entry-point that wraps the existing ingestPdf pipeline.
- ADR-0008 locks the ZIP layout (manifest.json + documents/<id>.pdf),
  the manifest schema (schemaVersion 1), and the merge-on-collision
  policy. JSZip is the only new dependency.
- App.tsx restructured: SessionProvider at the root, EngineProvider
  keyed by ${sessionId}:${version}, hash routing #/s/<id>[/forms/demo],
  SessionMenu top-bar, CreateFirstSession empty state.
- New DocumentRemoved event for per-document delete cleanup in
  CollectionList; engine.documents.remove() is the new service method.

Tests:
- Unit: 16 SessionService lifecycle + persistence tests;
  per-session snapshot round-trip; PdfByteStore + ingestPdfFromFile;
  SessionArchive parser; exportSessionZip + importSessionZip with
  create + merge + corrupt-archive paths.
- DOM: UploadDropzone, session-scoped CollectionList delete,
  SessionMenu create/switch/rename, routing parser.
- E2E: tests/integration/session-export-reimport.dom.test.tsx walks
  the full create → annotate → export → reimport flow and asserts
  the additive merge (deduped doc + doubled evidence rows).
- Legacy E2Es updated to use a seed-session helper instead of the
  removed fixture-button flow.

Known limitation (documented in ADR-0008): re-importing your own
freshly-exported ZIP creates duplicate annotations. Forward pointer
left for an importBundleId follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:57:28 +02:00
8632f7b04a Implement CE-WP-0004 T01-T05: citation card export (Markdown + HTML)
Per-evidence-item export: click Export → Copy as Markdown / Copy as HTML
writes a portable citation card to the clipboard. Cmd/Ctrl+Shift+C
exports the active evidence as Markdown.

- ADR-0007 locks the Markdown + HTML output formats.
- New shared types: CitationCard, openContextUrl(), resolveSourceLabel().
- Engine renderers under src/engine/rendering/: renderCitationCardMarkdown,
  renderCitationCardHtml — snapshot-tested, escape-safe, BEM classes for HTML.
- src/work/useExportEvidence.ts wires engine + renderers + clipboard.
- EvidenceSidebar gains an Export popover per row + auto-dismissing toast.
- E2E test (tests/integration/citation-card-export-e2e.dom.test.tsx)
  walks PRD scenario steps 10-11 and asserts the clipboard payload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:43:17 +02:00
69 changed files with 7274 additions and 407 deletions

View File

@@ -0,0 +1,115 @@
# ADR-0007 — Citation card output format (Markdown and HTML)
- Status: accepted
- Date: 2026-05-25
- Workplan: CE-WP-0004-T02 (Markdown renderer) and CE-WP-0004-T03 (HTML renderer)
- Spec refs: `wiki/ArchitectureOverview.md` §4.7, §14.1, §14.2, §14.3
## Context
The MVP scenario ends with a user exporting an evidence item as a portable
citation card. Two formats ship in CE-WP-0004:
- **Markdown** — copied to the clipboard for pasting into notes, emails,
GitHub issues, and so on. Renders well as plain text and as rendered
Markdown.
- **HTML** — copied for pasting into rich-text editors and web pages.
A third format, the `<citation-card>` Web Component from
`ArchitectureOverview.md` §14.2, is out of scope here and lands in a later
workplan. Its visual presentation should be *equivalent* to the HTML form
but is not constrained to be byte-identical.
The two formats need a written contract so that:
1. UI components (T04 sidebar export, future web embeds) can rely on the
exact output structure.
2. Snapshot tests fail loudly if the format drifts.
3. Consumers that style the HTML form know which elements and classes are
stable.
## Decision
### Markdown format (CE-WP-0004-T02)
```markdown
> {quote}
*{sourceLabel}* · [Open source]({openContextUrl})
{commentary}
```
Rules:
- Each `{quote}` line is rendered with the leading `> ` blockquote marker,
preserving line breaks in the source quote. A single-line quote is one
blockquote line; a multi-line quote becomes multiple `> `-prefixed
lines.
- A blank line follows the blockquote.
- The attribution line uses an em dash (`—`, U+2014) followed by a single
space, the italicised source label, a middle dot (`·`, U+00B7) with
surrounding spaces, and the `[Open source]({openContextUrl})` link.
- The middle dot + link segment is **omitted entirely** when no
`openContextUrl` is provided (which is unusual but possible for
evidence items without an annotation).
- A blank line follows the attribution.
- The optional `{commentary}` paragraph is rendered as-is. When absent
the trailing blank line and commentary paragraph are both omitted.
- The output ends with a single trailing newline.
Reserved Markdown characters inside the quote are not escaped — the
intent is to reproduce the source text verbatim. The blockquote prefix
already neutralises the most dangerous reflow problems. The
`{sourceLabel}` is escaped to defuse `*`/`_` only; the link target is
URL-encoded by `openContextUrl()`.
### HTML format (CE-WP-0004-T003)
A single `<aside class="citation-card">` root element with this stable
structure:
```html
<aside class="citation-card">
<blockquote class="citation-card__quote">{escaped quote}</blockquote>
<p class="citation-card__attribution">
<cite class="citation-card__source">{escaped source label}</cite>
<a class="citation-card__link" href="{open context url}">Open source</a>
</p>
<div class="citation-card__commentary">{escaped commentary}</div>
</aside>
```
Rules:
- All user-supplied text is HTML-escaped (`&`, `<`, `>`, `"`, `'`).
- Inline styles are **not** emitted. Host pages provide the CSS.
- The `<a>` and the attribution `·` separator are omitted when no
`openContextUrl` is provided.
- The `<div class="citation-card__commentary">` is omitted when no
commentary is provided.
- Commentary is treated as plain text — no Markdown or raw HTML
passthrough. A future workplan can introduce a sanitiser if rich
commentary is required.
- The output ends with a single trailing newline.
### Class-name contract
The four BEM-style class names — `citation-card`, `citation-card__quote`,
`citation-card__attribution`, `citation-card__source`,
`citation-card__link`, `citation-card__commentary` — are part of the
public contract. They must not be renamed without an ADR superseding
this one.
## Consequences
- Snapshot tests in `src/engine/rendering/*.test.ts` lock these
formats. Intentional changes require updating both the snapshots and
this ADR.
- The Web Component planned for §14.2 will reuse the HTML structure
inside its shadow DOM, so the class names also become the
customisation surface for downstream stylesheets.
- The `openContextUrl` shape from
`wiki/ArchitectureOverview.md` §14.3 is now consumed by two
renderers; changing the URL scheme requires regenerating snapshots
and announcing via a new ADR.

View File

@@ -0,0 +1,134 @@
# ADR-0008 — Session archive format (ZIP layout, manifest schema, merge policy)
- Status: accepted
- Date: 2026-05-25
- Workplan: CE-WP-0005-T05 (schema), CE-WP-0005-T06 (export),
CE-WP-0005-T07 (import)
- Spec refs: `wiki/ProductRequirementsDocument.md` §20,
`wiki/ArchitectureOverview.md` §3.4, §14.3
## Context
The CE-WP-0005 demo loop ends with a user exporting an entire session
(documents, annotations, evidence, links) into a single `.zip`
archive and importing it back later. The archive needs to be the
**only** persistence mechanism the demo provides beyond a tab close —
no IndexedDB in this workplan — so its shape needs to be locked
before two parallel tasks (T06, T07) and the integration test (T08)
land on top of it.
Three things need a written contract:
1. **ZIP layout** — what files live in the archive, named how.
2. **manifest.json shape** — versioned JSON schema, validated on
import.
3. **Conflict policy** — what happens when an imported session's name
already exists in the receiving repository.
## Decision
### ZIP layout
```
manifest.json
documents/
<documentId>.pdf
```
- `<documentId>` is the engine-minted branded id (`doc_<uuid>`). Using
it as the filename means the manifest's `documentBindings[i]` can
cross-reference the binary file without an additional lookup table.
- Per-representation files (e.g. an extracted-text JSON alongside each
PDF) are intentionally deferred. The canonical text + selectors are
embedded in the engine snapshot inside `manifest.json`, so a
re-import can regenerate everything from the binary.
- Future archive variants (multi-attachment documents, Markdown
documents) extend by adding subdirectories under the archive root.
Importers must ignore unknown top-level entries so older clients
remain compatible with newer archives that add new file types.
### `manifest.json` shape (schemaVersion 1)
```ts
interface SessionArchiveManifest {
schemaVersion: 1;
exportedAt: string; // ISO-8601 UTC timestamp
session: {
id: SessionId; // sess_<uuid>
name: string; // trimmed display name
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
};
engine: EngineSnapshot; // shape from src/engine/persistence.ts
documentBindings: Array<{
documentId: DocumentId; // matches the engine's record
filename: string; // original filename from upload
fingerprint: string; // SHA-256 — used by the importer for dedup
}>;
}
```
The `engine` field is the same shape that `captureSnapshot()` produces
in `src/engine/persistence.ts`. Re-using it verbatim keeps the
in-memory ↔ archive round-trip a one-way conversion (snapshot ↔
JSON) instead of growing a parallel schema that would drift.
Unknown fields at the top level **must be preserved** on import (a
future client can write them) but unknown fields inside `session` or
`documentBindings[i]` are dropped — the import constructs typed
domain objects from the validated subset.
### Merge-on-name-collision policy (T07)
When an imported manifest's `session.name` matches an existing
session, the existing session is the **target** (`outcome:
"merged-into"`). Otherwise a fresh session is created with the
imported name (`outcome: "created"`).
Within the target session:
- **Documents** are deduped by `fingerprint` (SHA-256 over the PDF
bytes). If a document with the same fingerprint already exists,
the import keeps the existing `documentId` and records a remap
from the incoming id. The binary file is **skipped** (we already
have the bytes). Otherwise a fresh `documentId` is minted and the
bytes go into the per-session byte store.
- **Annotations**, **evidence items**, and **evidence links** are
imported **additively**: each gets a freshly minted id, with any
`documentId`/`annotationId`/`evidenceItemId` references rewritten
via the remap. No update-in-place, no overwrite-by-id.
#### Known limitation: re-importing your own export duplicates annotations
Because annotations/evidence/links are always added with fresh ids,
re-importing a ZIP you just exported into the same session creates a
second copy of every annotation (the existing PDF bytes dedupe
correctly via fingerprint, but the annotations have nothing to
de-dupe against).
This is intentional for the demo loop and documented here so it's not
mistaken for a bug. A future workplan can introduce an
`importBundleId` field (a UUID minted at export time, stamped onto
the manifest and on every annotation/evidence-link the import
creates) plus a dedupe pass that skips entities already imported
under the same bundle id.
## Consequences
- **One source of truth for the engine snapshot.** Same shape on disk
and in memory; the persistence helpers stay re-usable.
- **Fingerprint-based dedup is byte-stable.** Two users converting
the same PDF end up with identical fingerprints; merging their
archives works as expected.
- **Idempotency is opt-in, not the default.** A user who wants exact
round-trips must use a future `importBundleId` flow, not the basic
T07 import.
- **Forward-compatible additions are cheap.** New top-level keys land
by adding fields; old importers preserve them and new importers
consume them.
## Status
Accepted. The TypeScript types + `parseSessionArchiveManifest` in
`src/shared/session-archive.ts` are the executable contract for
schemaVersion 1.

View File

@@ -19,6 +19,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"jszip": "^3.10.1",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",
"react-dom": "^18.3.1",

80
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
jszip:
specifier: ^3.10.1
version: 3.10.1
pdfjs-dist:
specifier: ^4.4.168
version: 4.10.38
@@ -1500,6 +1503,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1892,6 +1898,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1900,6 +1909,9 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -2007,6 +2019,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -2043,6 +2058,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2050,6 +2068,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2231,6 +2252,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2312,6 +2336,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2346,6 +2373,9 @@ packages:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -2378,6 +2408,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2434,6 +2467,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2568,6 +2604,9 @@ packages:
'@types/react':
optional: true
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -4052,6 +4091,8 @@ snapshots:
convert-source-map@2.0.0: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4542,6 +4583,8 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4549,6 +4592,8 @@ snapshots:
imurmurhash@0.1.4: {}
inherits@2.0.4: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4667,6 +4712,8 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -4691,6 +4738,13 @@ snapshots:
json5@2.2.3: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -4700,6 +4754,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -4876,6 +4934,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
process-nextick-args@2.0.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -4975,6 +5035,16 @@ snapshots:
dependencies:
loose-envify: 1.4.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -5054,6 +5124,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -5095,6 +5167,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.2
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -5167,6 +5241,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@@ -5326,6 +5404,8 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.29
util-deprecate@1.0.2: {}
vite-node@2.1.9(@types/node@20.19.41):
dependencies:
cac: 6.7.14

View File

@@ -1,81 +1,198 @@
/**
* App — the citation-evidence MVP shell.
* App — citation-evidence demo shell (CE-WP-0005).
*
* Composes the two top-level layouts:
* Composition:
*
* - Review mode (CE-WP-0002): collection list / viewer / evidence sidebar.
* - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip,
* with click-to-link interaction.
* SessionProvider (cross-session)
* └─ AppShell — owns routing + the top bar
* ├─ if no active session → CreateFirstSession (empty state)
* └─ else
* EngineProvider key={sessionId} sessionId={sessionId}
* └─ BinderProvider bus={engine.bus}
* └─ ReviewLayout | FormsApp (per `mode`)
*
* Mode selection is driven by `location.hash`: `#/forms/demo` lands in
* Forms mode; anything else (including empty) lands in Review mode. The
* top bar toggles between them. We keep the hash sync so reload + deep
* links work; T08's E2E asserts the `/forms/demo` navigation path.
*
* Engine and binder providers are both mounted at the App root so
* evidence/annotations/links survive switching tabs.
* The hash is the single source of truth for `{sessionId, mode}`. The
* SessionService's active id is kept in sync with the hash via a
* useEffect inside `AppShell`. Deep links to unknown sessions redirect
* to the empty state with a toast.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BinderProvider } from "@binder/index";
import {
EngineProvider,
SessionProvider,
useActiveSession,
useEngine,
usePdfByteStore,
useSessionByteStoreRegistry,
useSessionService,
useSessionsHydrated,
useSessionVersion,
useSessionVersionBumper,
} from "@work/index";
import { FormsApp } from "./forms/FormsApp";
import { ReviewLayout } from "./ReviewLayout";
type Mode = "review" | "forms";
import {
CreateFirstSession,
EMPTY_ROUTE,
exportSessionZip,
importSessionZip,
parseRoute,
navigateTo,
SessionMenu,
sessionZipFilename,
Toast,
triggerSessionDownload,
UploadDropzone,
useToast,
type AppMode,
type AppRoute,
} from "./sessions";
const FORMS_HASH = "#/forms/demo";
function readModeFromHash(): Mode {
if (typeof window === "undefined") return "review";
return window.location.hash === FORMS_HASH ? "forms" : "review";
function readRoute(): AppRoute {
if (typeof window === "undefined") return EMPTY_ROUTE;
return parseRoute(window.location.hash);
}
function writeModeToHash(mode: Mode) {
if (typeof window === "undefined") return;
const target = mode === "forms" ? FORMS_HASH : "";
if (window.location.hash !== target) {
if (target) {
window.location.hash = target;
} else {
// Clear hash without leaving "#" trailing in the URL bar.
history.replaceState(null, "", window.location.pathname + window.location.search);
}
}
}
function ModeRouter() {
const [mode, setMode] = useState<Mode>(() => readModeFromHash());
function useHashRoute(): AppRoute {
const [route, setRoute] = useState<AppRoute>(() => readRoute());
useEffect(() => {
function onHash() {
setMode(readModeFromHash());
}
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
const handler = () => setRoute(readRoute());
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);
return route;
}
const handleModeChange = (next: Mode) => {
writeModeToHash(next);
setMode(next);
};
function AppShell() {
const route = useHashRoute();
const service = useSessionService();
const hydrated = useSessionsHydrated();
const toast = useToast();
// Guards the "unknown session id → toast + redirect" path against an
// infinite loop: `useToast.show` creates a fresh `toast` object every
// render, which would otherwise re-fire the effect.
const lastHandledSessionIdRef = useRef<string | null>(null);
// Sync hash → SessionService.setActive. Unknown session ids fall back
// to the empty state with a toast.
useEffect(() => {
if (!hydrated) return;
const key = route.sessionId ?? "";
if (lastHandledSessionIdRef.current === key) return;
lastHandledSessionIdRef.current = key;
if (route.sessionId === null) {
service.setActive(null);
return;
}
const exists = service.get(route.sessionId);
if (exists) {
service.setActive(route.sessionId);
} else {
toast.show("Session not found — opened the empty state instead", "error");
navigateTo(EMPTY_ROUTE);
}
}, [route.sessionId, service, hydrated, toast]);
if (!hydrated) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
fontFamily: "system-ui, sans-serif",
color: "#888",
}}
>
Loading
</div>
);
}
if (route.sessionId === null) {
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<EmptyTopBar />
<div style={{ flex: 1, minHeight: 0 }}>
<CreateFirstSession />
</div>
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</div>
);
}
return <ActiveAppFrame route={route} toast={toast} />;
}
function ActiveAppFrame({
route,
toast,
}: {
route: AppRoute;
toast: ReturnType<typeof useToast>;
}) {
// EngineProvider remounts whenever the session id OR the per-session
// version counter changes. Import-into-active-session bumps the version
// so the new state from storage is picked up.
const sessionId = route.sessionId!;
const version = useSessionVersion(sessionId);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
<TopBar mode={mode} onModeChange={handleModeChange} />
<div style={{ flex: 1, minHeight: 0 }}>
{mode === "review" ? <ReviewLayout /> : <FormsApp />}
</div>
<EngineProvider key={`${sessionId}:${version}`} sessionId={sessionId}>
<ActiveTopBar route={route} showToast={toast.show} />
<div style={{ flex: 1, minHeight: 0 }}>
<SessionScopedTree mode={route.mode} />
</div>
</EngineProvider>
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</div>
);
}
function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
function SessionScopedTree({ mode }: { mode: AppMode }) {
const engine = useEngine();
return (
<BinderProvider bus={engine.bus}>
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />}
</BinderProvider>
);
}
function EmptyTopBar() {
const sessionService = useSessionService();
const registry = useSessionByteStoreRegistry();
const bumpVersion = useSessionVersionBumper();
const toast = useToast(); // local toast — empty state has its own
const handleImport = useCallback(async (file: File) => {
try {
const result = await importSessionZip(file, {
sessionService,
getOrCreateByteStore: registry.getOrCreateByteStore,
bumpSessionVersion: bumpVersion,
});
navigateTo({ sessionId: result.sessionId, mode: "review" });
toast.show(
result.outcome === "created"
? "Imported as a new session"
: "Merged into existing session",
"success",
);
} catch (err) {
toast.show(
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
"error",
);
}
}, [sessionService, registry, bumpVersion, toast]);
return (
<header
style={{
@@ -89,20 +206,122 @@ function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
}}
>
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
<button
onClick={() => onModeChange("review")}
aria-pressed={mode === "review"}
style={tabStyle(mode === "review")}
>
Review
</button>
<button
onClick={() => onModeChange("forms")}
aria-pressed={mode === "forms"}
style={tabStyle(mode === "forms")}
>
Forms
</button>
<SessionMenu onImportZip={() => pickAndImport(handleImport)} />
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</header>
);
}
function pickAndImport(onPicked: (file: File) => void): void {
if (typeof document === "undefined") return;
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip,application/zip";
input.onchange = () => {
const file = input.files?.[0];
if (file) onPicked(file);
};
input.click();
}
function ActiveTopBar({
route,
showToast,
}: {
route: AppRoute;
showToast: (msg: string, tone?: "success" | "error" | "info") => void;
}) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const session = useActiveSession();
const sessionService = useSessionService();
const registry = useSessionByteStoreRegistry();
const bumpVersion = useSessionVersionBumper();
const handleModeChange = useCallback(
(next: AppMode) => {
if (!route.sessionId) return;
navigateTo({ sessionId: route.sessionId, mode: next });
},
[route.sessionId],
);
const handleExport = useCallback(async () => {
if (!session) return;
try {
const blob = await exportSessionZip(engine, byteStore, session);
triggerSessionDownload(blob, sessionZipFilename(session));
showToast("Session exported", "success");
} catch (err) {
showToast(
err instanceof Error ? `Export failed: ${err.message}` : "Export failed",
"error",
);
}
}, [engine, byteStore, session, showToast]);
const handleImport = useCallback(
async (file: File) => {
try {
const result = await importSessionZip(file, {
sessionService,
getOrCreateByteStore: registry.getOrCreateByteStore,
bumpSessionVersion: bumpVersion,
});
navigateTo({ sessionId: result.sessionId, mode: "review" });
const totals = result.stats;
const summary =
result.outcome === "created"
? `Imported new session — ${totals.documentsAdded} document${totals.documentsAdded === 1 ? "" : "s"}, ${totals.annotationsAdded} annotation${totals.annotationsAdded === 1 ? "" : "s"}`
: `Merged into existing — ${totals.documentsAdded} new doc${totals.documentsAdded === 1 ? "" : "s"}, ${totals.documentsDeduped} deduped`;
showToast(summary, "success");
} catch (err) {
showToast(
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
"error",
);
}
},
[sessionService, registry, bumpVersion, showToast],
);
const tabs = useMemo(
() => [
{ id: "review" as const, label: "Review" },
{ id: "forms" as const, label: "Forms" },
],
[],
);
return (
<header
style={{
display: "flex",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid #ddd",
background: "#fafafa",
fontFamily: "system-ui, sans-serif",
alignItems: "center",
}}
>
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
<SessionMenu
onExportZip={() => void handleExport()}
onImportZip={() => pickAndImport((file) => void handleImport(file))}
/>
<div style={{ display: "flex", gap: 4, marginLeft: 12 }}>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => handleModeChange(t.id)}
aria-pressed={route.mode === t.id}
style={tabStyle(route.mode === t.id)}
>
{t.label}
</button>
))}
</div>
</header>
);
}
@@ -118,19 +337,10 @@ function tabStyle(active: boolean) {
};
}
function AppInner() {
const engine = useEngine();
return (
<BinderProvider bus={engine.bus}>
<ModeRouter />
</BinderProvider>
);
}
export function App() {
return (
<EngineProvider>
<AppInner />
</EngineProvider>
<SessionProvider>
<AppShell />
</SessionProvider>
);
}

View File

@@ -5,15 +5,27 @@
* │ Collection │ Document Viewer │ Evidence │
* │ List │ │ Sidebar │
* └────────────┴──────────────────┴────────────┘
*
* CE-WP-0005 added an `upload` slot for the active session's upload
* dropzone, threaded in by the app composition root so this component
* stays inside the `work` boundary (which cannot import `app`).
*/
import type { ReactNode } from "react";
import {
CollectionList,
EvidenceSidebar,
ViewerShell,
useActiveSession,
} from "@work/index";
export function ReviewLayout() {
export interface ReviewLayoutProps {
readonly upload?: ReactNode;
}
export function ReviewLayout({ upload }: ReviewLayoutProps) {
const session = useActiveSession();
return (
<div
style={{
@@ -22,7 +34,7 @@ export function ReviewLayout() {
fontFamily: "system-ui, sans-serif",
}}
>
<CollectionList />
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
<ViewerShell />
<EvidenceSidebar />
</div>

View File

@@ -0,0 +1,104 @@
/**
* Empty-state landing — shown when no session is active.
*
* Inline name input + Create button. On success, navigates the hash to
* the new session so the rest of the app mounts. Used both on first
* launch (no sessions yet) and after the last session was deleted.
*/
import { useCallback, useState } from "react";
import { useSessionService } from "@work/index";
import { navigateTo } from "./routing";
export function CreateFirstSession() {
const service = useSessionService();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const hasOthers = service.list().length > 0;
const handleCreate = useCallback(() => {
setError(null);
try {
const created = service.create(name);
navigateTo({ sessionId: created.id, mode: "review" });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [name, service]);
return (
<div
data-testid="empty-state"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
fontFamily: "system-ui, sans-serif",
background: "#fafafa",
gap: 12,
}}
>
<h1 style={{ fontSize: 22, margin: 0 }}>citation-evidence</h1>
<p style={{ fontSize: 14, color: "#555", margin: 0 }}>
{hasOthers
? "Pick a session from the menu above, or create a new one."
: "Create your first session to get started."}
</p>
<div style={{ display: "flex", gap: 6 }}>
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Session name (e.g. Lease 2024)"
data-testid="empty-state-input"
style={{
fontSize: 14,
padding: "6px 10px",
border: "1px solid #888",
borderRadius: 3,
minWidth: 260,
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
<button
type="button"
onClick={handleCreate}
data-testid="empty-state-create"
style={{
fontSize: 14,
padding: "6px 14px",
border: "1px solid #0050b3",
background: "#0050b3",
color: "white",
borderRadius: 3,
cursor: "pointer",
}}
>
Create session
</button>
</div>
{error && (
<div
data-testid="empty-state-error"
style={{
fontSize: 12,
color: "#7a0000",
background: "#fff4f4",
padding: "4px 10px",
border: "1px solid #f5cccc",
borderRadius: 3,
}}
>
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
/**
* SampleSessions — optional fixture-driven quick-start.
*
* The MVP collection list (pre-CE-WP-0005) ingested fixture PDFs over
* `fetch`. After the session refactor that workflow is no longer the
* default; it survives here as an optional way to seed the active
* session with a sample document for demo and testing.
*
* Mounted by `SessionMenu` (T04) under a "Sample sessions ▸" entry
* and by the integration tests under CE-WP-0002-T09 / -T05 that need
* a known-good document.
*/
import { useCallback, useState } from "react";
import { ingestPdf } from "@source/index";
import type { DocumentId } from "@shared/ids";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "@work/index";
import manifest from "../../../fixtures/pdfs/manifest.json";
interface Fixture {
id: string;
filename: string;
description: string;
page_count: number;
}
const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures;
export function SampleSessions() {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { id: activeId, setId } = useActiveDocumentId();
const [loadingFixtureId, setLoadingFixtureId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
const handleLoad = useCallback(
async (fixture: Fixture) => {
setError(null);
const existing = byFixture[fixture.id];
if (existing) {
setId(existing);
return;
}
setLoadingFixtureId(fixture.id);
try {
const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`fetch ${url}${response.status}`);
}
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
const { document, representation } = await ingestPdf(bytes, {
filename: fixture.filename,
});
// Push the bytes into the byte store so the viewer can mount them via
// the same blob URL machinery used by the upload path. The document
// record carries the blob URL on `uri` for the viewer adapter.
const record = byteStore.put(document.id, bytes);
engine.documents.register({
document: { ...document, uri: record.blobUrl },
representation,
});
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
setId(document.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingFixtureId(null);
}
},
[byFixture, byteStore, engine, setId],
);
return (
<div data-testid="sample-sessions">
<p style={{ fontSize: 12, color: "#555", margin: "0 0 6px" }}>
Load a fixture PDF as a sample document for the active session.
</p>
{error && (
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
{error}
</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{FIXTURES.map((f) => {
const isLoading = loadingFixtureId === f.id;
const documentId = byFixture[f.id];
const isActive = documentId !== undefined && documentId === activeId;
return (
<li key={f.id} style={{ marginBottom: 6 }}>
<button
onClick={() => void handleLoad(f)}
disabled={isLoading}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: isActive ? "#e8f0ff" : "white",
border: "1px solid #ccc",
padding: 6,
cursor: isLoading ? "wait" : "pointer",
fontSize: 12,
}}
>
<div style={{ fontWeight: 600 }}>{f.id}</div>
<div style={{ color: "#666", fontSize: 11 }}>
{f.page_count} page{f.page_count === 1 ? "" : "s"}
{isLoading ? " · loading…" : isActive ? " · open" : ""}
</div>
</button>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { useEffect, useState } from "react";
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 { SessionId } from "@shared/ids";
import { SessionProvider, useSessionService } from "@work/index";
import { SessionMenu } from "./SessionMenu";
import { parseRoute } from "./routing";
function HashSync() {
// Mirrors the production AppShell effect: hash changes drive
// SessionService.setActive so useActiveSession() resolves correctly.
const service = useSessionService();
const [tick, setTick] = useState(0);
useEffect(() => {
const onHash = () => setTick((t) => t + 1);
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
useEffect(() => {
const route = parseRoute(window.location.hash);
if (route.sessionId && service.get(route.sessionId as SessionId)) {
service.setActive(route.sessionId as SessionId);
} else {
service.setActive(null);
}
}, [tick, service]);
return null;
}
function Wrap({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<HashSync />
{children}
</SessionProvider>
);
}
function CurrentHash() {
return <span data-testid="current-hash">{window.location.hash || "(empty)"}</span>;
}
function SeedTwo() {
const service = useSessionService();
if (service.list().length === 0) {
service.create({ name: "Alpha" });
service.create({ name: "Beta" });
}
return null;
}
beforeEach(() => {
globalThis.localStorage?.clear();
history.replaceState(null, "", window.location.pathname);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("SessionMenu", () => {
it("creating a new session navigates the hash to /s/<id>", async () => {
render(
<Wrap>
<CurrentHash />
<SessionMenu />
</Wrap>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(screen.getByTestId("session-menu-new"));
await user.type(screen.getByTestId("session-new-input"), "Demo");
await user.click(screen.getByTestId("session-new-confirm"));
await waitFor(() => {
const route = parseRoute(window.location.hash);
expect(route.sessionId).toMatch(/^sess_/);
expect(route.mode).toBe("review");
});
});
it("switching sessions writes the chosen id into the hash", async () => {
render(
<Wrap>
<SeedTwo />
<CurrentHash />
<SessionMenu />
</Wrap>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId("session-menu-toggle"));
const alphaBtn = await screen.findByText(/Alpha/);
await user.click(alphaBtn);
await waitFor(() => {
const route = parseRoute(window.location.hash);
expect(route.sessionId).not.toBeNull();
expect(route.mode).toBe("review");
});
});
it("rename rejects a duplicate name with an inline error", async () => {
render(
<Wrap>
<SeedTwo />
<SessionMenu />
</Wrap>,
);
const user = userEvent.setup();
// Switch to Alpha first so it becomes active and rename becomes available.
await user.click(screen.getByTestId("session-menu-toggle"));
const alphaBtn = await screen.findByText(/Alpha/);
await user.click(alphaBtn);
// Re-open menu (it closed after switch) and try rename → Beta (taken).
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(screen.getByTestId("session-menu-rename"));
const input = screen.getByTestId("session-rename-input") as HTMLInputElement;
// Clear existing value and type new
await user.clear(input);
await user.type(input, "Beta");
await user.click(screen.getByTestId("session-rename-confirm"));
const error = await screen.findByTestId("session-menu-error");
expect(error.textContent).toMatch(/already exists/);
});
});

View File

@@ -0,0 +1,362 @@
/**
* SessionMenu — top-bar dropdown that drives the SessionService.
*
* Holds the only place in the UI where sessions get created, renamed,
* deleted, and switched. Export/Import ZIP menu items are slots —
* T06/T07 wire them.
*
* Switching sessions writes the new id into the URL hash; the routing
* layer is the source of truth (see `routing.ts`). That keeps deep
* links + browser back/forward behaving naturally.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { Session } from "@shared/session";
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
import { navigateTo } from "./routing";
interface SessionMenuProps {
readonly onExportZip?: () => void;
readonly onImportZip?: () => void;
readonly onOpenSamples?: () => void;
}
export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: SessionMenuProps) {
const service = useSessionService();
const tick = useSessionListTick();
const active = useActiveSession();
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [pendingDelete, setPendingDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const sessions = useMemo(() => {
// sorted by lastOpenedAt desc, then by createdAt desc
void tick;
const list = [...service.list()];
list.sort((a: Session, b: Session) => {
const aKey = a.lastOpenedAt ?? a.createdAt;
const bKey = b.lastOpenedAt ?? b.createdAt;
return bKey.localeCompare(aKey);
});
return list;
}, [service, tick]);
// Click outside closes the menu.
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setRenaming(false);
setPendingDelete(false);
}
};
window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler);
}, [open]);
const switchTo = useCallback(
(sessionId: import("@shared/ids").SessionId) => {
navigateTo({ sessionId, mode: "review" });
setOpen(false);
},
[],
);
const handleCreate = useCallback(() => {
setError(null);
try {
const created = service.create(newName);
setNewName("");
setCreating(false);
setOpen(false);
navigateTo({ sessionId: created.id, mode: "review" });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [newName, service]);
const handleRename = useCallback(() => {
if (!active) return;
setError(null);
try {
service.rename(active.id, renameValue);
setRenaming(false);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [active, renameValue, service]);
const handleDelete = useCallback(() => {
if (!active) return;
if (!pendingDelete) {
setPendingDelete(true);
return;
}
service.delete(active.id);
setPendingDelete(false);
setOpen(false);
navigateTo({ sessionId: null, mode: "review" });
}, [active, pendingDelete, service]);
return (
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
data-testid="session-menu-toggle"
onClick={() => setOpen((v) => !v)}
style={{
fontSize: 12,
padding: "4px 10px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
minWidth: 160,
textAlign: "left",
}}
>
{active ? active.name : "No session"}
<span style={{ float: "right", color: "#888" }}></span>
</button>
{open && (
<div
role="menu"
data-testid="session-menu-panel"
style={{
position: "absolute",
top: 28,
left: 0,
zIndex: 30,
background: "white",
border: "1px solid #888",
borderRadius: 3,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
padding: 4,
minWidth: 240,
fontSize: 12,
}}
>
{sessions.length > 0 && (
<>
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
Switch to
</div>
{sessions.map((s) => (
<button
key={s.id}
type="button"
role="menuitem"
data-testid={`session-switch-${s.id}`}
onClick={() => switchTo(s.id)}
style={{
...menuItemStyle,
background: active?.id === s.id ? "#e8f0ff" : "transparent",
}}
>
{s.name}
{active?.id === s.id ? " · open" : ""}
</button>
))}
<hr style={dividerStyle} />
</>
)}
{!creating && (
<button
type="button"
role="menuitem"
data-testid="session-menu-new"
onClick={() => {
setError(null);
setCreating(true);
setNewName("");
}}
style={menuItemStyle}
>
New session
</button>
)}
{creating && (
<div style={{ padding: 4, display: "flex", gap: 4 }}>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Session name"
data-testid="session-new-input"
style={{ flex: 1, fontSize: 12, padding: 4 }}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setCreating(false);
}}
/>
<button
type="button"
onClick={handleCreate}
data-testid="session-new-confirm"
style={smallButtonStyle}
>
Create
</button>
</div>
)}
{active && (
<>
<hr style={dividerStyle} />
{!renaming && (
<button
type="button"
role="menuitem"
data-testid="session-menu-rename"
onClick={() => {
setError(null);
setRenaming(true);
setRenameValue(active.name);
}}
style={menuItemStyle}
>
Rename
</button>
)}
{renaming && (
<div style={{ padding: 4, display: "flex", gap: 4 }}>
<input
autoFocus
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
data-testid="session-rename-input"
style={{ flex: 1, fontSize: 12, padding: 4 }}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
if (e.key === "Escape") setRenaming(false);
}}
/>
<button
type="button"
onClick={handleRename}
data-testid="session-rename-confirm"
style={smallButtonStyle}
>
Save
</button>
</div>
)}
<button
type="button"
role="menuitem"
data-testid="session-menu-delete"
onClick={handleDelete}
style={{ ...menuItemStyle, color: "#7a0000" }}
>
{pendingDelete ? "Confirm delete?" : "Delete…"}
</button>
</>
)}
{(onExportZip || onImportZip || onOpenSamples) && (
<hr style={dividerStyle} />
)}
{onExportZip && active && (
<button
type="button"
role="menuitem"
data-testid="session-menu-export"
onClick={() => {
setOpen(false);
onExportZip();
}}
style={menuItemStyle}
>
Export ZIP
</button>
)}
{onImportZip && (
<button
type="button"
role="menuitem"
data-testid="session-menu-import"
onClick={() => {
setOpen(false);
onImportZip();
}}
style={menuItemStyle}
>
Import ZIP
</button>
)}
{onOpenSamples && (
<button
type="button"
role="menuitem"
data-testid="session-menu-samples"
onClick={() => {
setOpen(false);
onOpenSamples();
}}
style={menuItemStyle}
>
Sample sessions
</button>
)}
{error && (
<div
data-testid="session-menu-error"
style={{
padding: 6,
background: "#fff4f4",
color: "#7a0000",
fontSize: 11,
marginTop: 4,
}}
>
{error}
</div>
)}
</div>
)}
</div>
);
}
const menuItemStyle: CSSProperties = {
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
};
const smallButtonStyle: CSSProperties = {
fontSize: 12,
padding: "2px 8px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
};
const dividerStyle: CSSProperties = {
border: "none",
borderTop: "1px solid #eee",
margin: "4px 0",
};

View File

@@ -0,0 +1,94 @@
/**
* Small reusable toast for session-scoped messages.
*
* Mirrors the CE-WP-0004 EvidenceSidebar pattern. Used by SessionMenu
* for "no such session" redirects, by T06 for export success/error,
* and by T07 for import results.
*/
import { useEffect, useState } from "react";
export type ToastTone = "success" | "error" | "info";
export interface ToastApi {
show(message: string, tone?: ToastTone): void;
dismiss(): void;
}
export interface ToastProps {
readonly toast: { readonly message: string; readonly tone: ToastTone; readonly key: number } | null;
readonly onDismiss: () => void;
readonly timeoutMs?: number;
}
export function Toast({ toast, onDismiss, timeoutMs = 3500 }: ToastProps) {
useEffect(() => {
if (!toast) return;
const t = setTimeout(onDismiss, timeoutMs);
return () => clearTimeout(t);
}, [toast, onDismiss, timeoutMs]);
if (!toast) return null;
return (
<div
role="status"
aria-live="polite"
data-testid="session-toast"
data-tone={toast.tone}
style={{
position: "fixed",
bottom: 16,
right: 16,
zIndex: 50,
padding: "8px 12px",
fontSize: 12,
background:
toast.tone === "success"
? "#d6f0d6"
: toast.tone === "error"
? "#f9d6d6"
: "#e0e8f5",
color:
toast.tone === "success"
? "#0a5a0a"
: toast.tone === "error"
? "#7a0000"
: "#003a7a",
border: `1px solid ${
toast.tone === "success"
? "#0a5a0a"
: toast.tone === "error"
? "#7a0000"
: "#003a7a"
}`,
borderRadius: 3,
fontFamily: "system-ui, sans-serif",
}}
>
{toast.message}
</div>
);
}
export function useToast(): {
toast: { message: string; tone: ToastTone; key: number } | null;
show(message: string, tone?: ToastTone): void;
dismiss(): void;
} {
const [toast, setToast] = useState<{ message: string; tone: ToastTone; key: number } | null>(
null,
);
const [, setCounter] = useState(0);
return {
toast,
show(message, tone = "info") {
setCounter((c) => {
const next = c + 1;
setToast({ message, tone, key: next });
return next;
});
},
dismiss() {
setToast(null);
},
};
}

View File

@@ -0,0 +1,93 @@
// @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 { EngineProvider } from "@work/index";
import { UploadDropzone } from "./UploadDropzone";
// Bypass PDF.js extraction in this DOM test. Mock `ingestPdfFromFile`
// (the entry point the dropzone calls) so it stamps a synthetic
// document onto the byte store without ever opening pdfjs.
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdfFromFile: vi.fn(
async (file: File | Blob, store: import("@source/index").PdfByteStore) => {
const filename =
"name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf";
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" +
Math.random().toString(36).slice(2, 10)) as RepresentationId;
const bytes = new Uint8Array(await file.arrayBuffer());
const record = store.put(documentId, bytes);
const document: Document = {
id: documentId,
mediaType: "application/pdf",
title: filename,
uri: record.blobUrl,
fingerprint: `synthetic-${documentId}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: `synthetic-${documentId}`,
canonicalText: "synthetic body",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 14, pageLength: 14 }],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
},
),
};
});
beforeEach(() => {
globalThis.localStorage?.clear();
// happy-dom's URL.createObjectURL returns blob:null/...; that's fine for tests.
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("UploadDropzone", () => {
it(
"ingests a dropped PDF and reports a 'done' progress entry",
{ timeout: 10000 },
async () => {
render(
<EngineProvider>
<UploadDropzone />
</EngineProvider>,
);
// happy-dom doesn't synthesise drag events well, so go through the
// file input — same processFiles path either way.
const input = screen.getByTestId("upload-file-input") as HTMLInputElement;
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
const file = new File([bytes], "demo.pdf", { type: "application/pdf" });
const user = userEvent.setup();
await user.upload(input, file);
await waitFor(() => {
const items = screen.getByTestId("upload-progress").querySelectorAll("li");
expect(items.length).toBe(1);
expect(items[0]?.getAttribute("data-status")).toBe("done");
});
},
);
});

View File

@@ -0,0 +1,189 @@
/**
* 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.
*/
import { useCallback, useRef, useState } from "react";
import { ingestPdfFromFile } from "@source/index";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "@work/index";
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>
);
}

View File

@@ -0,0 +1,154 @@
/**
* Round-trip an exported session through JSZip and assert the
* archive matches ADR-0008 (manifest + per-document PDF bytes).
*/
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { createEngine } from "@engine/index";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import { parseSessionArchiveManifest } from "@shared/session-archive";
import { createPdfByteStore } from "@source/index";
import { exportSessionZip, sessionZipFilename } from "./exportSessionZip";
function makeSession(id: string, name: string): Session {
return {
id: id as SessionId,
name,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
}
describe("exportSessionZip", () => {
it("produces a ZIP with manifest.json + documents/<id>.pdf for each binding", async () => {
const engine = createEngine();
const byteStore = createPdfByteStore({
createObjectURL: () => "blob:test-1",
revokeObjectURL: () => {},
});
const docId = "doc_test" as DocumentId;
const repId = "rep_test" as RepresentationId;
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
byteStore.put(docId, bytes);
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "demo.pdf",
fingerprint: "fingerprint-abc",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: "fingerprint-abc",
canonicalText: "Quoted passage.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 15, pageLength: 15 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
// Add an annotation + evidence item so the snapshot exercises that path.
const ann = engine.annotations.create({
documentId: docId,
representationId: repId,
quote: "Quoted",
selectors: [{ type: "TextQuoteSelector", exact: "Quoted" }],
});
engine.evidence.create({ annotationIds: [ann.id], commentary: "hi" });
const session = makeSession("sess_x", "Demo session");
const blob = await exportSessionZip(engine, byteStore, session, {
exportedAt: "2026-05-25T12:00:00.000Z",
});
expect(blob.size).toBeGreaterThan(0);
const arrayBuffer = await blob.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
expect(zip.file("manifest.json")).not.toBeNull();
expect(zip.file(`documents/${docId}.pdf`)).not.toBeNull();
const manifestText = await zip.file("manifest.json")!.async("string");
const manifest = parseSessionArchiveManifest(JSON.parse(manifestText));
expect(manifest.schemaVersion).toBe(1);
expect(manifest.session.id).toBe("sess_x");
expect(manifest.session.name).toBe("Demo session");
expect(manifest.documentBindings).toHaveLength(1);
expect(manifest.documentBindings[0]).toMatchObject({
documentId: docId,
filename: "demo.pdf",
fingerprint: "fingerprint-abc",
});
expect(manifest.engine.documents).toHaveLength(1);
expect(manifest.engine.representations).toHaveLength(1);
expect(manifest.engine.annotations).toHaveLength(1);
expect(manifest.engine.evidenceItems).toHaveLength(1);
const storedBytes = await zip.file(`documents/${docId}.pdf`)!.async("uint8array");
expect(Array.from(storedBytes)).toEqual(Array.from(bytes));
});
it("skips the binary file when the byte store has no bytes for a document", async () => {
const engine = createEngine();
const byteStore = createPdfByteStore({
createObjectURL: () => "blob:test-noop",
revokeObjectURL: () => {},
});
const docId = "doc_no_bytes" as DocumentId;
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "ghost.pdf",
fingerprint: "ghost-fp",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: "rep_no_bytes" as RepresentationId,
documentId: docId,
representationType: "pdf-text",
contentHash: "ghost-fp",
canonicalText: "",
pageMap: [],
offsetMap: [],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
const blob = await exportSessionZip(
engine,
byteStore,
makeSession("sess_nb", "No Bytes"),
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
expect(zip.file(`documents/${docId}.pdf`)).toBeNull();
const manifestText = await zip.file("manifest.json")!.async("string");
const manifest = parseSessionArchiveManifest(JSON.parse(manifestText));
expect(manifest.documentBindings).toHaveLength(1);
});
});
describe("sessionZipFilename", () => {
it("slugifies the session name and stamps the date in UTC", () => {
const session = makeSession("sess_a", "Lease — 2024 / München!");
const fixed = new Date(Date.UTC(2026, 4, 25, 14, 7));
expect(sessionZipFilename(session, fixed)).toBe("lease-2024-m-nchen-20260525-1407.zip");
});
it("falls back to 'session' when slugification produces empty string", () => {
const session = makeSession("sess_x", "!!!");
const fixed = new Date(Date.UTC(2026, 0, 1));
expect(sessionZipFilename(session, fixed)).toBe("session-20260101-0000.zip");
});
});

View File

@@ -0,0 +1,148 @@
/**
* `exportSessionZip` — pack a session's engine snapshot + uploaded PDF
* bytes into a single `.zip` archive (ADR-0008 layout).
*
* Steps:
* 1. Build the manifest from `captureSnapshot(engine)` + session
* metadata + per-document `{filename, fingerprint}` derived from
* `engine.documents`.
* 2. For each binding, push `bytes` into `documents/<documentId>.pdf`.
* 3. Push `manifest.json` (pretty-printed JSON).
* 4. `zip.generateAsync({ type: "blob" })`.
*
* `triggerSessionDownload` creates an `<a download>` link and clicks
* it. The filename is `<slug>-<isoDate>.zip` so two exports of the
* same session don't collide on disk.
*/
import JSZip from "jszip";
import { captureSnapshot, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import type { Session } from "@shared/session";
import {
SESSION_ARCHIVE_SCHEMA_VERSION,
type SessionArchiveDocumentBinding,
type SessionArchiveManifest,
} from "@shared/session-archive";
import type { PdfByteStore } from "@source/index";
export interface ExportSessionZipOptions {
/** Override the timestamp embedded in the manifest. */
readonly exportedAt?: string;
}
export async function exportSessionZip(
engine: Engine,
byteStore: PdfByteStore,
session: Session,
options: ExportSessionZipOptions = {},
): Promise<Blob> {
const snapshot = captureSnapshot(engine);
const documents = engine.documents.list();
const bindings: SessionArchiveDocumentBinding[] = [];
const zip = new JSZip();
const documentsFolder = zip.folder("documents");
if (!documentsFolder) {
throw new Error("exportSessionZip: JSZip refused to create 'documents/' folder");
}
for (const doc of documents) {
const filename =
doc.title ??
(typeof doc.metadata?.["filename"] === "string"
? (doc.metadata["filename"] as string)
: `${doc.id}.pdf`);
const fingerprint = doc.fingerprint ?? "";
bindings.push({ documentId: doc.id, filename, fingerprint });
const record = byteStore.get(doc.id);
if (record) {
documentsFolder.file(`${doc.id}.pdf`, record.bytes);
}
// If bytes are missing (e.g. fixture-loaded doc whose bytes weren't
// pushed into the store), the manifest still lists the binding but
// the binary is absent — the importer surfaces this as a warning
// in T07.
}
const manifest: SessionArchiveManifest = {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt: options.exportedAt ?? new Date().toISOString(),
session: {
id: session.id,
name: session.name,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
},
engine: snapshot,
documentBindings: bindings,
};
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
return zip.generateAsync({ type: "blob" });
}
export function sessionZipFilename(session: Session, now: Date = new Date()): string {
const slug =
session.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "session";
// YYYYMMDD-HHMM
const pad = (n: number) => String(n).padStart(2, "0");
const stamp = `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}`;
return `${slug}-${stamp}.zip`;
}
export interface TriggerDownloadHooks {
/** Override the `<a>` creation — used by tests to intercept the click. */
readonly createAnchor?: () => HTMLAnchorElement;
readonly createObjectURL?: (blob: Blob) => string;
readonly revokeObjectURL?: (url: string) => void;
}
export function triggerSessionDownload(
blob: Blob,
filename: string,
hooks: TriggerDownloadHooks = {},
): void {
const createObjectURL =
hooks.createObjectURL ??
((b: Blob) => {
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error("triggerSessionDownload: URL.createObjectURL unavailable");
}
return URL.createObjectURL(b);
});
const revokeObjectURL =
hooks.revokeObjectURL ??
((url: string) => {
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
});
const createAnchor =
hooks.createAnchor ??
(() => {
if (typeof document === "undefined") {
throw new Error("triggerSessionDownload: document is not available");
}
return document.createElement("a");
});
const url = createObjectURL(blob);
const a = createAnchor();
a.href = url;
a.download = filename;
a.click();
// Revoke after the click so the browser has a chance to start the download.
setTimeout(() => revokeObjectURL(url), 1_000);
}
// Re-export the DocumentId type so consumers can write
// `exportSessionZip(...)` without an extra import. Tree-shakeable.
export type { DocumentId };

View File

@@ -0,0 +1,276 @@
import { describe, expect, it } from "vitest";
import {
createEngine,
createEventBus,
createInMemorySessionRepository,
createSessionService,
engineSnapshotKey,
restoreFromStorage,
type SessionService,
} from "@engine/index";
import type {
DocumentId,
RepresentationId,
SessionId,
} from "@shared/ids";
import type { Session } from "@shared/session";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { exportSessionZip } from "./exportSessionZip";
import {
importSessionZip,
SessionImportError,
type ImportSessionServices,
} from "./importSessionZip";
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function makeService(): SessionService {
const repo = createInMemorySessionRepository();
const bus = createEventBus();
return createSessionService(repo, bus);
}
function freshStores() {
const stores = new Map<SessionId, PdfByteStore>();
return {
stores,
get(sessionId: SessionId): PdfByteStore {
let s = stores.get(sessionId);
if (!s) {
s = createPdfByteStore({
createObjectURL: () => `blob:t-${sessionId}-${Math.random()}`,
revokeObjectURL: () => {},
});
stores.set(sessionId, s);
}
return s;
},
};
}
interface Harness {
service: SessionService;
stores: ReturnType<typeof freshStores>["stores"];
byteStoreFor(sessionId: SessionId): PdfByteStore;
bumps: SessionId[];
storage: ReturnType<typeof memoryStorage>;
services: ImportSessionServices;
}
function harness(): Harness {
const service = makeService();
const stores = freshStores();
const bumps: SessionId[] = [];
const storage = memoryStorage();
return {
service,
stores: stores.stores,
byteStoreFor: stores.get,
bumps,
storage,
services: {
sessionService: service,
getOrCreateByteStore: stores.get,
bumpSessionVersion: (id) => bumps.push(id),
storage,
},
};
}
async function seedAndExport(opts: {
sessionName: string;
storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}): Promise<{ blob: Blob; session: Session; docId: DocumentId }> {
const engine = createEngine();
const byteStore = createPdfByteStore({
createObjectURL: () => "blob:src",
revokeObjectURL: () => {},
});
const session: Session = {
id: "sess_src" as SessionId,
name: opts.sessionName,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const docId = "doc_src" as DocumentId;
const repId = "rep_src" as RepresentationId;
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
byteStore.put(docId, bytes);
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "src.pdf",
fingerprint: "fp-shared",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: "fp-shared",
canonicalText: "The quote.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 10, pageLength: 10 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
const ann = engine.annotations.create({
documentId: docId,
representationId: repId,
quote: "The quote.",
selectors: [{ type: "TextQuoteSelector", exact: "The quote." }],
});
engine.evidence.create({ annotationIds: [ann.id], commentary: "important" });
const blob = await exportSessionZip(engine, byteStore, session);
// The "blob" JSZip produces inside a node test isn't a real Blob —
// re-pack as a fresh Blob over an ArrayBuffer so JSZip.loadAsync (in
// the importer) can consume it.
const buf = await blob.arrayBuffer();
const portableBlob = new Blob([buf], { type: "application/zip" });
// Silence unused-storage lint
void opts.storage;
return { blob: portableBlob, session, docId };
}
describe("importSessionZip — create path", () => {
it("imports a fresh session and stamps a new engine snapshot in storage", async () => {
const h = harness();
const { blob } = await seedAndExport({
sessionName: "From Export",
storage: h.storage,
});
const result = await importSessionZip(blob, h.services);
expect(result.outcome).toBe("created");
expect(result.sessionId).toMatch(/^sess_/);
expect(result.stats.documentsAdded).toBe(1);
expect(result.stats.documentsDeduped).toBe(0);
expect(result.stats.annotationsAdded).toBe(1);
expect(result.stats.evidenceAdded).toBe(1);
// The session record exists in the service.
const created = h.service.get(result.sessionId);
expect(created?.name).toBe("From Export");
// The engine snapshot was persisted to localStorage at the per-
// session key.
const raw = h.storage.getItem(engineSnapshotKey(result.sessionId));
expect(raw).not.toBeNull();
const restored = createEngine();
restoreFromStorage(restored, {
key: engineSnapshotKey(result.sessionId),
storage: h.storage,
});
expect(restored.documents.list()).toHaveLength(1);
expect(restored.annotations.listByDocument(restored.documents.list()[0]!.id)).toHaveLength(1);
// The byte store registry got the bytes.
const bytesStore = h.byteStoreFor(result.sessionId);
expect(bytesStore.list()).toHaveLength(1);
// setActive was called + version bumped.
expect(h.service.getActive()).toBe(result.sessionId);
expect(h.bumps).toContain(result.sessionId);
});
});
describe("importSessionZip — merge path", () => {
it("dedupes documents by fingerprint and adds annotations additively", async () => {
const h = harness();
// Pre-create a session with the same name + same fingerprint
// document so the merge has something to dedupe against.
const targetSession = h.service.create({ name: "Demo" });
{
const seedEngine = createEngine();
const seedStore = h.byteStoreFor(targetSession.id);
seedStore.put("doc_pre" as DocumentId, new Uint8Array([1]));
seedEngine.documents.register({
document: {
id: "doc_pre" as DocumentId,
mediaType: "application/pdf",
title: "pre.pdf",
fingerprint: "fp-shared",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: "rep_pre" as RepresentationId,
documentId: "doc_pre" as DocumentId,
representationType: "pdf-text",
contentHash: "fp-shared",
canonicalText: "x",
pageMap: [],
offsetMap: [],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
const seedSnap = await import("@engine/index").then((m) => m.captureSnapshot(seedEngine));
h.storage.setItem(engineSnapshotKey(targetSession.id), JSON.stringify(seedSnap));
}
const { blob } = await seedAndExport({
sessionName: "Demo",
storage: h.storage,
});
const result = await importSessionZip(blob, h.services);
expect(result.outcome).toBe("merged-into");
expect(result.sessionId).toBe(targetSession.id);
expect(result.stats.documentsAdded).toBe(0);
expect(result.stats.documentsDeduped).toBe(1);
expect(result.stats.annotationsAdded).toBe(1);
expect(result.stats.evidenceAdded).toBe(1);
// Re-load the snapshot — there should still be ONE document
// (deduped), and the annotation/evidence we added are now visible
// on that existing document.
const restored = createEngine();
restoreFromStorage(restored, {
key: engineSnapshotKey(targetSession.id),
storage: h.storage,
});
expect(restored.documents.list()).toHaveLength(1);
expect(restored.documents.list()[0]!.id).toBe("doc_pre" as DocumentId);
const annsOnDoc = restored.annotations.listByDocument("doc_pre" as DocumentId);
expect(annsOnDoc).toHaveLength(1);
expect(annsOnDoc[0]!.quote).toBe("The quote.");
});
});
describe("importSessionZip — error path", () => {
it("rejects an archive with a malformed manifest", async () => {
const h = harness();
// Build a minimal zip with a malformed manifest.
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
zip.file("manifest.json", JSON.stringify({ schemaVersion: 999, exportedAt: "x" }));
const buf = await zip.generateAsync({ type: "arraybuffer" });
const blob = new Blob([buf], { type: "application/zip" });
await expect(importSessionZip(blob, h.services)).rejects.toThrow(SessionImportError);
});
it("rejects an archive without a manifest", async () => {
const h = harness();
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
zip.file("something-else.txt", "hello");
const buf = await zip.generateAsync({ type: "arraybuffer" });
const blob = new Blob([buf], { type: "application/zip" });
await expect(importSessionZip(blob, h.services)).rejects.toThrow(/manifest\.json missing/);
});
});

View File

@@ -0,0 +1,314 @@
/**
* `importSessionZip` — read a session ZIP archive, dedupe documents by
* fingerprint, additively merge annotations/evidence/links into the
* target session. ADR-0008 is the authoritative spec.
*
* Target session resolution:
* - If a session with the manifest's `session.name` exists (case
* insensitive, matching SessionService rules), that's the target
* and `outcome` is `"merged-into"`.
* - Otherwise a fresh session is created with the imported name and
* `outcome` is `"created"`.
*
* Per-archive document handling:
* - SHA-256 fingerprint match against the target session's existing
* documents → reuse the existing `documentId`, skip the binary,
* record a remap.
* - No match → mint a new branded `documentId`, push the bytes into
* the target's byte store, register with the target's engine,
* record the remap.
*
* Per-archive annotation/evidence/link handling:
* - Always mint fresh ids; rewrite any `documentId` / `annotationId`
* / `evidenceItemId` references via the remap.
*
* Known limitation: re-importing your own export creates duplicate
* annotations (no idempotency). See ADR-0008 §"Known limitation" for
* the planned `importBundleId` follow-up.
*
* The importer works against a *fresh* off-React `Engine` for the
* target session and writes the resulting snapshot directly to
* `localStorage` at `engineSnapshotKey(targetSession.id)`. Callers
* then invoke `bumpSessionVersion(target.id)` to force the React
* EngineProvider to remount + restore the new snapshot.
*/
import JSZip from "jszip";
import type { Annotation } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import {
newId,
type AnnotationId,
type DocumentId,
type RepresentationId,
type SessionId,
} from "@shared/ids";
import {
parseSessionArchiveManifest,
type SessionArchiveDocumentBinding,
type SessionArchiveManifest,
} from "@shared/session-archive";
import {
captureSnapshot,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type SessionService,
} from "@engine/index";
import type { PdfByteStore } from "@source/index";
export interface ImportSessionServices {
readonly sessionService: SessionService;
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
bumpSessionVersion(sessionId: SessionId): void;
/** Storage shim — defaults to globalThis.localStorage. */
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
export type ImportOutcome = "created" | "merged-into";
export interface ImportSessionStats {
readonly documentsAdded: number;
readonly documentsDeduped: number;
readonly annotationsAdded: number;
readonly evidenceAdded: number;
readonly linksAdded: number;
}
export interface ImportSessionResult {
readonly sessionId: SessionId;
readonly outcome: ImportOutcome;
readonly stats: ImportSessionStats;
}
export class SessionImportError extends Error {
constructor(message: string) {
super(`Session import failed: ${message}`);
this.name = "SessionImportError";
}
}
export async function importSessionZip(
file: File | Blob,
services: ImportSessionServices,
): Promise<ImportSessionResult> {
const storage = services.storage ?? globalThis.localStorage;
if (!storage) {
throw new SessionImportError("no storage available");
}
// 1. Open the zip + parse the manifest.
const zip = await loadZip(file);
const manifestEntry = zip.file("manifest.json");
if (!manifestEntry) {
throw new SessionImportError("manifest.json missing from archive");
}
let manifest: SessionArchiveManifest;
try {
const text = await manifestEntry.async("string");
manifest = parseSessionArchiveManifest(JSON.parse(text));
} catch (err) {
throw new SessionImportError(
err instanceof Error ? err.message : `manifest parse failed: ${String(err)}`,
);
}
// 2. Read all binary files referenced by the manifest. We tolerate
// missing files — they appear as 0 documents added for that binding.
const incomingBytes = new Map<DocumentId, Uint8Array>();
for (const binding of manifest.documentBindings) {
const entry = zip.file(`documents/${binding.documentId}.pdf`);
if (entry) {
incomingBytes.set(binding.documentId, await entry.async("uint8array"));
}
}
// 3. Resolve target session.
const matchingExisting = services.sessionService
.list()
.find((s) => s.name.trim().toLocaleLowerCase() === manifest.session.name.trim().toLocaleLowerCase());
let targetSessionId: SessionId;
let outcome: ImportOutcome;
if (matchingExisting) {
targetSessionId = matchingExisting.id;
outcome = "merged-into";
} else {
const created = services.sessionService.create({ name: manifest.session.name });
targetSessionId = created.id;
outcome = "created";
}
// 4. Build an off-React engine for the target — populated either from
// the target's existing snapshot (merge path) or empty (create path).
const targetEngine = createEngine();
if (outcome === "merged-into") {
restoreFromStorage(targetEngine, {
key: engineSnapshotKey(targetSessionId),
storage,
});
}
const targetByteStore = services.getOrCreateByteStore(targetSessionId);
// 5. Build the document remap.
const docRemap = new Map<DocumentId, DocumentId>();
const existingByFingerprint = new Map<string, DocumentId>();
for (const doc of targetEngine.documents.list()) {
if (doc.fingerprint) existingByFingerprint.set(doc.fingerprint, doc.id);
}
let documentsAdded = 0;
let documentsDeduped = 0;
const incomingDocs = manifest.engine.documents as readonly Document[];
const incomingReps = manifest.engine.representations as readonly DocumentRepresentation[];
for (const binding of manifest.documentBindings) {
const remappedExisting = existingByFingerprint.get(binding.fingerprint);
if (remappedExisting) {
docRemap.set(binding.documentId, remappedExisting);
documentsDeduped += 1;
continue;
}
const incomingDoc = incomingDocs.find((d) => d.id === binding.documentId);
if (!incomingDoc) {
// Manifest pointed to a binding without an engine record for it —
// skip silently, matches the "tolerate missing files" rule.
continue;
}
const newDocId = newId("document");
const incomingDocReps = incomingReps.filter((r) => r.documentId === binding.documentId);
// Push bytes into the byte store; mint a fresh blob URL on the way.
const bytes = incomingBytes.get(binding.documentId);
const blobUrl = bytes ? targetByteStore.put(newDocId, bytes).blobUrl : undefined;
const newDoc: Document = {
...incomingDoc,
id: newDocId,
...(blobUrl !== undefined ? { uri: blobUrl } : {}),
};
const newReps: DocumentRepresentation[] = incomingDocReps.map((rep) => ({
...rep,
id: newId("representation") as RepresentationId,
documentId: newDocId,
}));
const firstRep = newReps[0];
if (firstRep) {
// Use the service for the first rep so events fire + dedup logic
// in the repos runs. Extra reps go in via the repo directly.
targetEngine.documents.register({ document: newDoc, representation: firstRep });
for (let i = 1; i < newReps.length; i++) {
targetEngine.repos.representations.create(newReps[i]!);
}
} else {
// Engine snapshot somehow lacks a representation — push the doc
// directly so the snapshot stays self-consistent.
targetEngine.repos.documents.create(newDoc);
}
docRemap.set(binding.documentId, newDocId);
documentsAdded += 1;
}
// 6. Remap annotations.
const annRemap = new Map<AnnotationId, AnnotationId>();
let annotationsAdded = 0;
const incomingAnns = manifest.engine.annotations as readonly Annotation[];
for (const ann of incomingAnns) {
const newDocId = docRemap.get(ann.documentId);
if (!newDocId) continue; // orphan — no doc imported
const newAnnId = newId("annotation");
const newAnn: Annotation = {
...ann,
id: newAnnId,
documentId: newDocId,
};
// Write through the repo + emit AnnotationCreated so any future
// listeners (none in T07 itself) get the event. Mirrors the
// snapshot-restore pattern.
targetEngine.repos.annotations.create(newAnn);
targetEngine.bus.emit({
type: "AnnotationCreated",
annotationId: newAnnId,
annotation: newAnn,
});
annRemap.set(ann.id, newAnnId);
annotationsAdded += 1;
}
// 7. Remap evidence items.
let evidenceAdded = 0;
const incomingEvidence = manifest.engine.evidenceItems as readonly EvidenceItem[];
for (const item of incomingEvidence) {
const newAnnIds: AnnotationId[] = [];
for (const aid of item.annotationIds) {
const remapped = annRemap.get(aid);
if (remapped) newAnnIds.push(remapped);
}
if (newAnnIds.length === 0) continue;
const newEvId = newId("evidence");
const newItem: EvidenceItem = {
...item,
id: newEvId,
annotationIds: newAnnIds,
};
targetEngine.repos.evidenceItems.create(newItem);
targetEngine.bus.emit({
type: "EvidenceItemCreated",
evidenceItemId: newEvId,
evidenceItem: newItem,
});
evidenceAdded += 1;
}
// 8. EvidenceLinks live on the binder, not the engine snapshot. The
// schema-version-1 manifest does not carry them yet — `linksAdded`
// stays 0 until a future ADR extends the snapshot.
const linksAdded = 0;
// 9. Persist the merged snapshot directly to the per-session storage
// key. The version bump (below) forces the EngineProvider to remount
// and restore from there.
const snapshot = captureSnapshot(targetEngine);
try {
storage.setItem(engineSnapshotKey(targetSessionId), JSON.stringify(snapshot));
} catch (err) {
throw new SessionImportError(
`failed to persist target snapshot: ${err instanceof Error ? err.message : String(err)}`,
);
}
// 10. Make the target active + bump its version so React picks up the
// new state.
services.sessionService.setActive(targetSessionId);
services.bumpSessionVersion(targetSessionId);
return {
sessionId: targetSessionId,
outcome,
stats: {
documentsAdded,
documentsDeduped,
annotationsAdded,
evidenceAdded,
linksAdded,
},
};
}
async function loadZip(file: File | Blob): Promise<JSZip> {
try {
// Convert to ArrayBuffer first — JSZip can't always consume a Blob
// in Node (which the test runner uses), but ArrayBuffer is portable.
const buf = await file.arrayBuffer();
return await JSZip.loadAsync(buf);
} catch (err) {
throw new SessionImportError(
`corrupt ZIP: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// Re-export the binding type for callers that want to inspect manifests.
export type { SessionArchiveDocumentBinding };

28
src/app/sessions/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export { UploadDropzone, type UploadDropzoneProps } from "./UploadDropzone";
export { SampleSessions } from "./SampleSessions";
export { SessionMenu } from "./SessionMenu";
export { CreateFirstSession } from "./CreateFirstSession";
export { Toast, useToast, type ToastTone } from "./Toast";
export {
EMPTY_ROUTE,
navigateTo,
parseRoute,
serializeRoute,
type AppMode,
type AppRoute,
} from "./routing";
export {
exportSessionZip,
sessionZipFilename,
triggerSessionDownload,
type ExportSessionZipOptions,
type TriggerDownloadHooks,
} from "./exportSessionZip";
export {
importSessionZip,
SessionImportError,
type ImportOutcome,
type ImportSessionResult,
type ImportSessionServices,
type ImportSessionStats,
} from "./importSessionZip";

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { SessionId } from "@shared/ids";
import { EMPTY_ROUTE, parseRoute, serializeRoute } from "./routing";
describe("routing.parseRoute", () => {
it("returns the empty route for an empty hash", () => {
expect(parseRoute("")).toEqual(EMPTY_ROUTE);
expect(parseRoute("#")).toEqual(EMPTY_ROUTE);
expect(parseRoute("#/")).toEqual(EMPTY_ROUTE);
});
it("parses #/s/<id> as review mode for that session", () => {
const route = parseRoute("#/s/sess_abc");
expect(route.sessionId).toBe("sess_abc");
expect(route.mode).toBe("review");
});
it("parses #/s/<id>/forms/demo as forms mode", () => {
const route = parseRoute("#/s/sess_xyz/forms/demo");
expect(route.sessionId).toBe("sess_xyz");
expect(route.mode).toBe("forms");
});
it("treats legacy #/forms/demo as the empty route (session must be chosen first)", () => {
expect(parseRoute("#/forms/demo")).toEqual(EMPTY_ROUTE);
});
it("trims trailing slashes", () => {
expect(parseRoute("#/s/sess_abc/")).toMatchObject({ sessionId: "sess_abc" });
});
});
describe("routing.serializeRoute", () => {
it("returns empty string for the empty route", () => {
expect(serializeRoute(EMPTY_ROUTE)).toBe("");
});
it("round-trips review mode", () => {
const route = { sessionId: "sess_abc" as SessionId, mode: "review" as const };
expect(serializeRoute(route)).toBe("#/s/sess_abc");
expect(parseRoute(serializeRoute(route))).toEqual(route);
});
it("round-trips forms mode", () => {
const route = { sessionId: "sess_xyz" as SessionId, mode: "forms" as const };
expect(serializeRoute(route)).toBe("#/s/sess_xyz/forms/demo");
expect(parseRoute(serializeRoute(route))).toEqual(route);
});
});

View File

@@ -0,0 +1,61 @@
/**
* Hash routing for the demo app.
*
* #/ → empty state ("create your first session")
* #/s/<sessionId> → review mode, scoped to <sessionId>
* #/s/<sessionId>/forms/demo → forms mode, scoped to <sessionId>
*
* The hash is the single source of truth for the active session and the
* active mode. `SessionProvider.setActive(...)` is wired as a side
* effect of hash changes so back/forward and deep links behave
* naturally.
*/
import type { SessionId } from "@shared/ids";
export type AppMode = "review" | "forms";
export interface AppRoute {
readonly sessionId: SessionId | null;
readonly mode: AppMode;
}
export const EMPTY_ROUTE: AppRoute = { sessionId: null, mode: "review" };
export function parseRoute(hash: string): AppRoute {
// Normalise: drop leading "#", trim any trailing slashes.
const cleaned = hash.replace(/^#/, "").replace(/^\/+|\/+$/g, "");
if (cleaned === "") return EMPTY_ROUTE;
const parts = cleaned.split("/");
if (parts.length >= 2 && parts[0] === "s") {
const sessionId = parts[1]! as SessionId;
const mode: AppMode =
parts[2] === "forms" && parts[3] === "demo" ? "forms" : "review";
return { sessionId, mode };
}
// Legacy `#/forms/demo` (pre-CE-WP-0005) maps to the empty state — the
// user has to pick a session first.
return EMPTY_ROUTE;
}
export function serializeRoute(route: AppRoute): string {
if (!route.sessionId) return "";
const base = `#/s/${route.sessionId}`;
return route.mode === "forms" ? `${base}/forms/demo` : base;
}
export function navigateTo(route: AppRoute): void {
if (typeof window === "undefined") return;
const target = serializeRoute(route);
if (target === "") {
// Clear the hash entirely so the URL stays clean.
history.replaceState(null, "", window.location.pathname + window.location.search);
// history.replaceState doesn't fire hashchange — dispatch one so
// subscribers re-read.
window.dispatchEvent(new HashChangeEvent("hashchange"));
return;
}
if (window.location.hash !== target) {
window.location.hash = target;
}
}

View File

@@ -21,7 +21,9 @@ import type {
EvidenceItemId,
EvidenceLinkId,
RepresentationId,
SessionId,
} from "@shared/ids";
import type { Session } from "@shared/session";
export interface DocumentImportedEvent {
readonly type: "DocumentImported";
@@ -36,6 +38,11 @@ export interface DocumentRepresentationGeneratedEvent {
readonly representation: DocumentRepresentation;
}
export interface DocumentRemovedEvent {
readonly type: "DocumentRemoved";
readonly documentId: DocumentId;
}
export interface AnnotationCreatedEvent {
readonly type: "AnnotationCreated";
readonly annotationId: AnnotationId;
@@ -92,9 +99,34 @@ export interface FormFieldActivatedEvent {
readonly previousTarget?: EvidenceTarget;
}
export interface SessionCreatedEvent {
readonly type: "SessionCreated";
readonly sessionId: SessionId;
readonly session: Session;
}
export interface SessionRenamedEvent {
readonly type: "SessionRenamed";
readonly sessionId: SessionId;
readonly session: Session;
readonly previousName: string;
}
export interface SessionDeletedEvent {
readonly type: "SessionDeleted";
readonly sessionId: SessionId;
}
export interface SessionActivatedEvent {
readonly type: "SessionActivated";
readonly sessionId: SessionId | null;
readonly previousSessionId: SessionId | null;
}
export type EngineEvent =
| DocumentImportedEvent
| DocumentRepresentationGeneratedEvent
| DocumentRemovedEvent
| AnnotationCreatedEvent
| AnnotationResolvedEvent
| AnnotationResolutionFailedEvent
@@ -103,7 +135,11 @@ export type EngineEvent =
| EvidenceItemActivatedEvent
| EvidenceLinkCreatedEvent
| EvidenceLinkUpdatedEvent
| FormFieldActivatedEvent;
| FormFieldActivatedEvent
| SessionCreatedEvent
| SessionRenamedEvent
| SessionDeletedEvent
| SessionActivatedEvent;
export type EngineEventType = EngineEvent["type"];

View File

@@ -27,6 +27,7 @@ import {
export * from "./events";
export * from "./repos";
export * from "./services";
export * from "./rendering";
export {
SNAPSHOT_VERSION,
attachPersister,

View File

@@ -0,0 +1,172 @@
/**
* HTML citation card renderer tests (CE-WP-0004-T03).
*
* Snapshots lock the output format defined in
* `docs/decisions/ADR-0007-citation-card-format.md`. Class names are
* part of the public contract — renaming any of them requires updating
* both this test and the ADR.
*/
import { describe, expect, it } from "vitest";
import type { Annotation } from "@shared/annotation";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
import { renderCitationCardHtml } from "./html";
const DOC_ID = "doc_2024-order" as DocumentId;
const REP_ID = "rep_2024-order" as RepresentationId;
const ANN_ID = "ann_para3" as AnnotationId;
const EV_ID = "ev_para3" as EvidenceItemId;
function makeDoc(overrides: Partial<Document> = {}): Document {
return {
id: DOC_ID,
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
return {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
quote: "Die Frist endet am 31. März 2024.",
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
return {
id: EV_ID,
annotationIds: [ANN_ID],
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
describe("renderCitationCardHtml()", () => {
it("renders the full aside with quote, attribution, and commentary", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&amp;annotation=ann_para3">Open source</a></p>
<div class="citation-card__commentary">Deadline clause for the buyer.</div>
</aside>
"
`);
});
it("omits the commentary div when none is set", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&amp;annotation=ann_para3">Open source</a></p>
</aside>
"
`);
});
it("converts \\n inside the quote to <br> for rich-text paste", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn({ quote: "Line one.\nLine two." }),
});
expect(out).toContain(
'<blockquote class="citation-card__quote">Line one.<br>Line two.</blockquote>',
);
});
it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({
commentary: `Notes: <script>alert("xss")</script> & 'untrusted'`,
}),
document: makeDoc({ title: `Order "draft" & more` }),
annotation: makeAnn({ quote: "<<value>> & 'inner'" }),
});
// Quote escaping
expect(out).toContain(
"<blockquote class=\"citation-card__quote\">&lt;&lt;value&gt;&gt; &amp; &#39;inner&#39;</blockquote>",
);
// Source label escaping
expect(out).toContain(
"<cite class=\"citation-card__source\">Order &quot;draft&quot; &amp; more</cite>",
);
// Commentary escaping — and especially: no raw <script>
expect(out).not.toContain("<script>");
expect(out).toContain(
"<div class=\"citation-card__commentary\">Notes: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; &amp; &#39;untrusted&#39;</div>",
);
});
it("falls back to metadata.filename when document.title is missing", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: {
id: DOC_ID,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
metadata: { filename: "order-2024.pdf" },
},
annotation: makeAnn(),
});
expect(out).toContain(
'<cite class="citation-card__source">order-2024.pdf</cite>',
);
});
it("drops the · separator and link when openContextUrlOverride is empty", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "",
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite></p>
</aside>
"
`);
});
it("does not emit inline style attributes (host page controls CSS)", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({ commentary: "Some commentary." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).not.toMatch(/style=/);
});
});

View File

@@ -0,0 +1,92 @@
/**
* HTML citation card renderer.
*
* Implements CE-WP-0004-T03. Output format and rules are locked in
* `docs/decisions/ADR-0007-citation-card-format.md`.
*
* The output is a single `<aside class="citation-card">` element with no
* inline styles — host pages provide the CSS. Commentary is treated as
* plain text; no Markdown or raw HTML passthrough.
*/
import type { Annotation } from "@shared/annotation";
import { resolveSourceLabel } from "@shared/citation-card-source";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import { openContextUrl } from "@shared/open-context-url";
export interface RenderCitationCardHtmlInput {
readonly evidenceItem: EvidenceItem;
readonly document: Document;
readonly annotation: Annotation;
/**
* Override the deep-link URL — same semantics as
* `renderCitationCardMarkdown` (host pages with a non-default mount
* prefix). Defaults to `openContextUrl({…})`.
*/
readonly openContextUrlOverride?: string;
}
const HTML_ENTITIES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => HTML_ENTITIES[c]!);
}
/**
* Convert source line breaks to `<br>` inside the blockquote so a
* multi-line quote stays multi-line in rich-text paste targets. Each
* line is HTML-escaped first.
*/
function escapeQuoteWithLineBreaks(quote: string): string {
return quote.split("\n").map(escapeHtml).join("<br>");
}
export function renderCitationCardHtml({
evidenceItem,
document,
annotation,
openContextUrlOverride,
}: RenderCitationCardHtmlInput): string {
const quote = annotation.quote ?? "";
const sourceLabel = resolveSourceLabel(document);
const url =
openContextUrlOverride !== undefined
? openContextUrlOverride
: openContextUrl({
documentId: document.id,
annotationId: annotation.id,
});
const quoteHtml = escapeQuoteWithLineBreaks(quote);
const sourceHtml = escapeHtml(sourceLabel);
const linkSegment =
url.length > 0
? ` · <a class="citation-card__link" href="${escapeHtml(url)}">Open source</a>`
: "";
const attributionHtml = `<cite class="citation-card__source">${sourceHtml}</cite>${linkSegment}`;
const lines = [
'<aside class="citation-card">',
` <blockquote class="citation-card__quote">${quoteHtml}</blockquote>`,
` <p class="citation-card__attribution">${attributionHtml}</p>`,
];
if (evidenceItem.commentary) {
const commentaryHtml = escapeHtml(evidenceItem.commentary);
lines.push(
` <div class="citation-card__commentary">${commentaryHtml}</div>`,
);
}
lines.push("</aside>");
return lines.join("\n") + "\n";
}

View File

@@ -0,0 +1,8 @@
export {
renderCitationCardMarkdown,
type RenderCitationCardMarkdownInput,
} from "./markdown";
export {
renderCitationCardHtml,
type RenderCitationCardHtmlInput,
} from "./html";

View File

@@ -0,0 +1,194 @@
/**
* Markdown citation card renderer tests (CE-WP-0004-T02).
*
* Snapshots lock the output format defined in
* `docs/decisions/ADR-0007-citation-card-format.md`. If a snapshot
* intentionally changes, update ADR-0007 in the same commit so the
* written contract and the runtime stay in sync.
*/
import { describe, expect, it } from "vitest";
import type { Annotation } from "@shared/annotation";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
import { renderCitationCardMarkdown } from "./markdown";
const DOC_ID = "doc_2024-order" as DocumentId;
const REP_ID = "rep_2024-order" as RepresentationId;
const ANN_ID = "ann_para3" as AnnotationId;
const EV_ID = "ev_para3" as EvidenceItemId;
function makeDoc(overrides: Partial<Document> = {}): Document {
return {
id: DOC_ID,
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
return {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
quote: "Die Frist endet am 31. März 2024.",
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
return {
id: EV_ID,
annotationIds: [ANN_ID],
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
describe("renderCitationCardMarkdown()", () => {
it("renders quote + attribution + commentary", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
Deadline clause for the buyer.
"
`);
});
it("omits the commentary paragraph when none is set", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("preserves multi-line quotes by prefixing each line with '> '", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn({ quote: "Line one.\nLine two.\nLine three." }),
});
expect(out).toMatchInlineSnapshot(`
"> Line one.
> Line two.
> Line three.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("falls back to metadata.filename when document.title is missing", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: {
id: DOC_ID,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
metadata: { filename: "order-2024.pdf" },
},
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *order-2024.pdf* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("escapes * and _ in the source label to keep italics intact", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc({ title: "Order *2024_v2*" }),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order \\*2024\\_v2\\** · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("omits the open-context link when override is empty", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "",
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024*
"
`);
});
it("honours an openContextUrlOverride for mounted-prefix hosts", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "https://citations.example.test/v?d=foo&a=bar",
});
expect(out).toContain(
"[Open source](https://citations.example.test/v?d=foo&a=bar)",
);
});
it("renders an empty blockquote line when the annotation has no quote", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
});
expect(out).toMatchInlineSnapshot(`
">
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
});

View File

@@ -0,0 +1,68 @@
/**
* Markdown citation card renderer.
*
* Implements CE-WP-0004-T02. Output format and rules are locked in
* `docs/decisions/ADR-0007-citation-card-format.md`.
*
* Inputs are passed by value (not looked up from a service) so this
* renderer can be unit-tested without an engine and reused by future
* batch-export jobs.
*/
import type { Annotation } from "@shared/annotation";
import { resolveSourceLabel } from "@shared/citation-card-source";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import { openContextUrl } from "@shared/open-context-url";
export interface RenderCitationCardMarkdownInput {
readonly evidenceItem: EvidenceItem;
readonly document: Document;
readonly annotation: Annotation;
/**
* Override the deep-link URL — useful for hosts that mount the viewer
* under a non-default prefix. Defaults to `openContextUrl({…})`.
*/
readonly openContextUrlOverride?: string;
}
/**
* Escape `*` and `_` inside the source label so it doesn't accidentally
* break out of the surrounding `*…*` italic span. No other escaping is
* performed — citations should reproduce the source text verbatim.
*/
function escapeSourceLabel(label: string): string {
return label.replace(/[*_]/g, (m) => `\\${m}`);
}
export function renderCitationCardMarkdown({
evidenceItem,
document,
annotation,
openContextUrlOverride,
}: RenderCitationCardMarkdownInput): string {
const quote = annotation.quote ?? "";
const sourceLabel = resolveSourceLabel(document);
const url =
openContextUrlOverride !== undefined
? openContextUrlOverride
: openContextUrl({
documentId: document.id,
annotationId: annotation.id,
});
const quoteLines = quote.length === 0 ? [""] : quote.split("\n");
// Trailing whitespace on a blockquote line can be misread as a hard
// line break (`<br>`) by some Markdown engines; trimEnd keeps an
// empty line as bare `>`.
const quoteBlock = quoteLines.map((line) => `> ${line}`.trimEnd()).join("\n");
const attributionLink = url.length > 0 ? ` · [Open source](${url})` : "";
const attribution = `— *${escapeSourceLabel(sourceLabel)}*${attributionLink}`;
const sections = [quoteBlock, attribution];
if (evidenceItem.commentary) sections.push(evidenceItem.commentary);
return sections.join("\n\n") + "\n";
}

View File

@@ -0,0 +1,47 @@
/**
* In-memory `Map`-backed `SessionRepository`.
*
* Sister to `in-memory.ts` but for `Session` objects. The session repo
* lives *outside* the per-session `Engine` (one repo for the whole app;
* each session's engine snapshot is keyed by `session.id`). Keeping it
* in the same `engine/repos` directory keeps the storage layer
* conventions together so the eventual ADR-0005 swap touches one
* neighbourhood.
*/
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
export interface SessionRepository {
create(session: Session): Session;
get(id: SessionId): Session | null;
list(): readonly Session[];
update(session: Session): Session;
delete(id: SessionId): boolean;
}
export function createInMemorySessionRepository(): SessionRepository {
const sessions = new Map<SessionId, Session>();
return {
create(session) {
sessions.set(session.id, session);
return session;
},
get(id) {
return sessions.get(id) ?? null;
},
list() {
return [...sessions.values()];
},
update(session) {
if (!sessions.has(session.id)) {
throw new Error(`SessionRepository.update: unknown id ${session.id}`);
}
sessions.set(session.id, session);
return session;
},
delete(id) {
return sessions.delete(id);
},
};
}

View File

@@ -25,12 +25,14 @@ export interface DocumentRepository {
get(id: DocumentId): Document | null;
list(): readonly Document[];
update(document: Document): Document;
delete(id: DocumentId): boolean;
}
export interface RepresentationRepository {
create(representation: DocumentRepresentation): DocumentRepresentation;
get(id: RepresentationId): DocumentRepresentation | null;
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
deleteByDocument(documentId: DocumentId): number;
}
export interface AnnotationRepository {
@@ -82,6 +84,9 @@ export function createInMemoryRepos(): InMemoryRepos {
documents.set(document.id, document);
return document;
},
delete(id) {
return documents.delete(id);
},
},
representations: {
create(representation) {
@@ -98,6 +103,16 @@ export function createInMemoryRepos(): InMemoryRepos {
}
return out;
},
deleteByDocument(documentId) {
let removed = 0;
for (const [id, rep] of representations) {
if (rep.documentId === documentId) {
representations.delete(id);
removed += 1;
}
}
return removed;
},
},
annotations: {
create(annotation) {

View File

@@ -6,3 +6,7 @@ export {
type AnnotationRepository,
type EvidenceItemRepository,
} from "./in-memory";
export {
createInMemorySessionRepository,
type SessionRepository,
} from "./in-memory-sessions";

View File

@@ -23,6 +23,7 @@ export interface DocumentService {
list(): readonly Document[];
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
remove(id: DocumentId): boolean;
}
export function createDocumentService(
@@ -59,5 +60,15 @@ export function createDocumentService(
listRepresentations(documentId) {
return representations.listByDocument(documentId);
},
remove(id) {
const existing = documents.get(id);
if (!existing) return false;
representations.deleteByDocument(id);
const removed = documents.delete(id);
if (removed) {
bus.emit({ type: "DocumentRemoved", documentId: id });
}
return removed;
},
};
}

View File

@@ -12,3 +12,16 @@ export {
type EvidenceService,
type CreateEvidenceItemInput,
} from "./evidence";
export {
ACTIVE_SESSION_KEY,
attachSessionPersister,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,
restoreSessionsFromStorage,
SESSIONS_INDEX_KEY,
type CreateSessionInput,
type RestoreSessionsResult,
type SessionPersisterOptions,
type SessionService,
} from "./sessions";

View File

@@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import { createEventBus, type EventBus, type EngineEvent } from "../events";
import {
createInMemorySessionRepository,
type SessionRepository,
} from "../repos";
import {
ACTIVE_SESSION_KEY,
attachSessionPersister,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,
restoreSessionsFromStorage,
SESSIONS_INDEX_KEY,
type SessionService,
} from "./sessions";
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function freshService(): {
service: SessionService;
repo: SessionRepository;
bus: EventBus;
events: EngineEvent[];
} {
const repo = createInMemorySessionRepository();
const bus = createEventBus();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
const service = createSessionService(repo, bus);
return { service, repo, bus, events };
}
describe("engineSnapshotKey", () => {
it("formats as citation-evidence:session:<id>:engine-snapshot:v1", () => {
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
"citation-evidence:session:sess_abc:engine-snapshot:v1",
);
});
});
describe("SessionService — lifecycle", () => {
let s: ReturnType<typeof freshService>;
beforeEach(() => {
s = freshService();
});
it("creates a session and emits SessionCreated", () => {
const created = s.service.create("Lease 2024");
expect(created.name).toBe("Lease 2024");
expect(created.id).toMatch(/^sess_/);
expect(s.events).toHaveLength(1);
expect(s.events[0]!.type).toBe("SessionCreated");
});
it("trims whitespace in names", () => {
const created = s.service.create(" Trimmed ");
expect(created.name).toBe("Trimmed");
});
it("rejects empty names", () => {
expect(() => s.service.create(" ")).toThrow(/must not be empty/);
});
it("rejects case-insensitive duplicates", () => {
s.service.create("Demo");
expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError);
expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError);
});
it("rename emits SessionRenamed with previousName", () => {
const created = s.service.create("Old");
s.events.length = 0;
const renamed = s.service.rename(created.id, "New");
expect(renamed.name).toBe("New");
expect(s.events).toHaveLength(1);
const evt = s.events[0]!;
expect(evt.type).toBe("SessionRenamed");
if (evt.type === "SessionRenamed") {
expect(evt.previousName).toBe("Old");
}
});
it("rename rejects a duplicate (but allows renaming to your own current name)", () => {
const a = s.service.create("Alpha");
s.service.create("Beta");
expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError);
// self-rename is fine
const same = s.service.rename(a.id, "Alpha");
expect(same.name).toBe("Alpha");
});
it("delete emits SessionDeleted and clears active if it was the active one", () => {
const created = s.service.create("To Delete");
s.service.setActive(created.id);
s.events.length = 0;
const ok = s.service.delete(created.id);
expect(ok).toBe(true);
const types = s.events.map((e) => e.type);
expect(types).toContain("SessionActivated"); // active cleared first
expect(types).toContain("SessionDeleted");
expect(s.service.getActive()).toBeNull();
});
it("delete on an unknown id is a no-op (returns false, no events)", () => {
const ok = s.service.delete("sess_missing" as SessionId);
expect(ok).toBe(false);
expect(s.events).toHaveLength(0);
});
it("setActive on an unknown id throws", () => {
expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/);
});
it("setActive emits SessionActivated with previousSessionId", () => {
const a = s.service.create("A");
const b = s.service.create("B");
s.events.length = 0;
s.service.setActive(a.id);
s.service.setActive(b.id);
const activated = s.events.filter((e) => e.type === "SessionActivated");
expect(activated).toHaveLength(2);
if (activated[1]!.type === "SessionActivated") {
expect(activated[1]!.previousSessionId).toBe(a.id);
expect(activated[1]!.sessionId).toBe(b.id);
}
});
it("setActive to the same id is a no-op (no event)", () => {
const a = s.service.create("A");
s.service.setActive(a.id);
s.events.length = 0;
s.service.setActive(a.id);
expect(s.events).toHaveLength(0);
});
it("setActive stamps lastOpenedAt", () => {
const a = s.service.create("A");
expect(a.lastOpenedAt).toBeUndefined();
s.service.setActive(a.id);
const reread = s.service.get(a.id);
expect(reread?.lastOpenedAt).toBeTruthy();
});
});
describe("attachSessionPersister + restoreSessionsFromStorage", () => {
it("round-trips a session index through storage", () => {
const storage = memoryStorage();
const src = freshService();
attachSessionPersister(src.service, src.bus, { storage });
const a = src.service.create("Alpha");
const b = src.service.create("Beta");
src.service.setActive(b.id);
// Read-back into a fresh service.
const dst = freshService();
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
expect(result.restored).toBe(true);
expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]);
expect(result.activeSessionId).toBe(b.id);
expect(dst.service.getActive()).toBe(b.id);
// a is still in the repo too
expect(dst.service.get(a.id)?.name).toBe("Alpha");
});
it("returns {restored:false} when storage is empty", () => {
const storage = memoryStorage();
const dst = freshService();
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
expect(result.restored).toBe(false);
expect(result.sessions).toHaveLength(0);
expect(result.activeSessionId).toBeNull();
});
it("delete clears both the index entry and the per-session snapshot key", () => {
const storage = memoryStorage();
const src = freshService();
attachSessionPersister(src.service, src.bus, { storage });
const created = src.service.create("Doomed");
// Pretend an engine snapshot was written by the per-session persister.
storage.setItem(engineSnapshotKey(created.id), "{}");
src.service.delete(created.id);
expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull();
const raw = storage.getItem(SESSIONS_INDEX_KEY);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.sessions).toHaveLength(0);
expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,302 @@
/**
* SessionService — lifecycle for `Session` records.
*
* Lives *above* the per-session `Engine` (the engine itself is recreated
* each time the active session changes). The service owns its own
* `EventBus` instance — independent of any engine bus — but uses the
* same `EngineEvent` shape so consumers can subscribe with the standard
* `bus.on("SessionCreated", …)` pattern.
*
* Per-session engine snapshot persistence is handled by attaching the
* existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })`
* inside the app's `EngineProvider`. The helpers in this file own the
* *cross-session* storage: the session index + the active-session
* pointer.
*
* Naming rules:
* - Names are trimmed on input.
* - Case-insensitive uniqueness — two sessions cannot coexist with
* names that differ only in case ("Demo" vs "demo"). This avoids
* surprising the ZIP-merge path in T07, which uses `session.name`
* to find an existing target.
*/
import { newId } from "@shared/ids";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import type { EventBus } from "../events";
import type { SessionRepository } from "../repos";
const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1";
const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1";
export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY };
/**
* Build the engine-snapshot storage key for a given session.
*
* Format: `citation-evidence:session:<sessionId>:engine-snapshot:v1`.
* The `v1` tail leaves room for a future migration that changes the
* snapshot shape without sweeping the legacy keys.
*/
export function engineSnapshotKey(sessionId: SessionId): string {
return `citation-evidence:session:${sessionId}:engine-snapshot:v1`;
}
export class DuplicateSessionNameError extends Error {
constructor(name: string) {
super(`Session with name "${name}" already exists`);
this.name = "DuplicateSessionNameError";
}
}
export interface CreateSessionInput {
readonly name: string;
/** Override the generated id — primarily for tests and importers. */
readonly id?: SessionId;
readonly now?: string;
}
export interface SessionService {
create(input: string | CreateSessionInput): Session;
rename(id: SessionId, name: string): Session;
delete(id: SessionId): boolean;
list(): readonly Session[];
get(id: SessionId): Session | null;
setActive(id: SessionId | null): void;
getActive(): SessionId | null;
/** Record an "I just opened this" timestamp on the session. */
touch(id: SessionId): Session | null;
}
function nowIso(now?: string): string {
return now ?? new Date().toISOString();
}
function normalisedName(name: string): { display: string; key: string } {
const display = name.trim();
return { display, key: display.toLocaleLowerCase() };
}
export function createSessionService(
repo: SessionRepository,
bus: EventBus,
): SessionService {
let activeId: SessionId | null = null;
function assertUniqueName(name: string, exceptId?: SessionId) {
const { key } = normalisedName(name);
for (const existing of repo.list()) {
if (exceptId && existing.id === exceptId) continue;
if (existing.name.trim().toLocaleLowerCase() === key) {
throw new DuplicateSessionNameError(name);
}
}
}
return {
create(input) {
const { name, id, now } =
typeof input === "string" ? { name: input, id: undefined, now: undefined } : input;
const { display } = normalisedName(name);
if (display.length === 0) {
throw new Error("SessionService.create: name must not be empty");
}
assertUniqueName(display);
const ts = nowIso(now);
const session: Session = {
id: id ?? newId("session"),
name: display,
createdAt: ts,
updatedAt: ts,
};
const stored = repo.create(session);
bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored });
return stored;
},
rename(id, name) {
const existing = repo.get(id);
if (!existing) {
throw new Error(`SessionService.rename: unknown session ${id}`);
}
const { display } = normalisedName(name);
if (display.length === 0) {
throw new Error("SessionService.rename: name must not be empty");
}
assertUniqueName(display, id);
const previousName = existing.name;
const next: Session = {
...existing,
name: display,
updatedAt: nowIso(),
};
const stored = repo.update(next);
bus.emit({
type: "SessionRenamed",
sessionId: stored.id,
session: stored,
previousName,
});
return stored;
},
delete(id) {
const removed = repo.delete(id);
if (removed) {
if (activeId === id) {
const previousSessionId = activeId;
activeId = null;
bus.emit({
type: "SessionActivated",
sessionId: null,
previousSessionId,
});
}
bus.emit({ type: "SessionDeleted", sessionId: id });
}
return removed;
},
list() {
return repo.list();
},
get(id) {
return repo.get(id);
},
setActive(id) {
if (id !== null && !repo.get(id)) {
throw new Error(`SessionService.setActive: unknown session ${id}`);
}
if (id === activeId) return;
const previousSessionId = activeId;
activeId = id;
if (id) {
const existing = repo.get(id);
if (existing) {
repo.update({ ...existing, lastOpenedAt: nowIso() });
}
}
bus.emit({
type: "SessionActivated",
sessionId: id,
previousSessionId,
});
},
getActive() {
return activeId;
},
touch(id) {
const existing = repo.get(id);
if (!existing) return null;
return repo.update({ ...existing, lastOpenedAt: nowIso() });
},
};
}
// ---------------------------------------------------------------------------
// Cross-session persistence (the session index + active-session pointer).
// Per-session engine snapshots are handled by `attachPersister` against
// `engineSnapshotKey(sessionId)`.
// ---------------------------------------------------------------------------
export interface SessionPersisterOptions {
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
interface SessionsFile {
readonly version: 1;
readonly sessions: readonly Session[];
readonly activeSessionId: SessionId | null;
}
export function attachSessionPersister(
service: SessionService,
bus: EventBus,
options: SessionPersisterOptions = {},
): () => void {
const storage = options.storage ?? globalThis.localStorage;
const writeIndex = () => {
const file: SessionsFile = {
version: 1,
sessions: service.list(),
activeSessionId: service.getActive(),
};
try {
storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file));
if (file.activeSessionId) {
storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId);
} else {
storage.removeItem(ACTIVE_SESSION_KEY);
}
} catch (err) {
console.warn("attachSessionPersister: write failed", err);
}
};
const writeIndexAndCleanup = () => {
writeIndex();
};
const writeOnDelete = (sessionId: SessionId) => {
writeIndex();
try {
storage.removeItem(engineSnapshotKey(sessionId));
} catch (err) {
console.warn("attachSessionPersister: snapshot cleanup failed", err);
}
};
const offs = [
bus.on("SessionCreated", writeIndexAndCleanup),
bus.on("SessionRenamed", writeIndexAndCleanup),
bus.on("SessionActivated", writeIndexAndCleanup),
bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)),
];
return () => {
for (const off of offs) off();
};
}
export interface RestoreSessionsResult {
readonly restored: boolean;
readonly sessions: readonly Session[];
readonly activeSessionId: SessionId | null;
}
/**
* Hydrate the session repo from storage *without* firing events. Mirrors
* `restoreSnapshot`'s "direct repo write" pattern so the persister
* (which is attached after restore) doesn't immediately re-write what
* we just read.
*/
export function restoreSessionsFromStorage(
repo: SessionRepository,
service: SessionService,
options: SessionPersisterOptions = {},
): RestoreSessionsResult {
const storage = options.storage ?? globalThis.localStorage;
const raw = storage.getItem(SESSIONS_INDEX_KEY);
if (!raw) return { restored: false, sessions: [], activeSessionId: null };
try {
const parsed = JSON.parse(raw) as Partial<SessionsFile>;
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
return { restored: false, sessions: [], activeSessionId: null };
}
for (const s of parsed.sessions) repo.create(s);
const activeRaw =
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null;
const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY);
const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null;
let activeSessionId: SessionId | null = null;
if (candidate && repo.get(candidate)) {
activeSessionId = candidate;
// Use service.setActive to keep the in-memory activeId aligned —
// suppress the resulting event so we don't bounce the persister.
// The bus listener attached *after* restore is what does the
// persistence, so emitting here is harmless either way; but
// skipping it keeps restore strictly side-effect-free from the
// listener's point of view.
service.setActive(activeSessionId);
}
return { restored: true, sessions: repo.list(), activeSessionId };
} catch (err) {
console.warn("restoreSessionsFromStorage: parse failed", err);
return { restored: false, sessions: [], activeSessionId: null };
}
}

View File

@@ -0,0 +1,98 @@
/**
* Per-session engine snapshot round-trip.
*
* The workplan (CE-WP-0005-T01) requires that two sessions persisted
* under the per-session key scheme can each be restored independently
* — proving the storage layout actually partitions data by session.
*/
import { describe, expect, it } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import {
attachPersister,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type Engine,
type EngineSnapshot,
} from "./index";
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function seedDoc(engine: Engine, label: string): { id: DocumentId } {
const id = `doc_${label}` as DocumentId;
const repId = `rep_${label}` as RepresentationId;
const document: Document = {
id,
mediaType: "application/pdf",
title: `Doc ${label}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: repId,
documentId: id,
representationType: "pdf-text",
contentHash: `hash-${label}`,
canonicalText: `text for ${label}`,
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }],
generatedAt: "2026-05-25T00:00:00.000Z",
};
engine.documents.register({ document, representation });
return { id };
}
describe("per-session engine snapshot round-trip", () => {
it("keeps two sessions' snapshots isolated under per-session storage keys", () => {
const storage = memoryStorage();
const sessA = "sess_aaa" as SessionId;
const sessB = "sess_bbb" as SessionId;
// Author session A.
const engineA = createEngine();
const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage });
const a1 = seedDoc(engineA, "a1");
const a2 = seedDoc(engineA, "a2");
offA();
// Author session B with completely different documents.
const engineB = createEngine();
const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage });
const b1 = seedDoc(engineB, "b1");
offB();
// Restore each into its own fresh engine; assert isolation.
const restoredA = createEngine();
const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage });
expect(resA.restored).toBe(true);
const aIds = restoredA.documents.list().map((d) => d.id).sort();
expect(aIds).toEqual([a1.id, a2.id].sort());
const restoredB = createEngine();
const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage });
expect(resB.restored).toBe(true);
const bIds = restoredB.documents.list().map((d) => d.id);
expect(bIds).toEqual([b1.id]);
// Sanity: each snapshot key really does hold a distinct snapshot.
const rawA = storage.getItem(engineSnapshotKey(sessA));
const rawB = storage.getItem(engineSnapshotKey(sessB));
expect(rawA).not.toBeNull();
expect(rawB).not.toBeNull();
const snapA = JSON.parse(rawA!) as EngineSnapshot;
const snapB = JSON.parse(rawB!) as EngineSnapshot;
expect(snapA.documents).toHaveLength(2);
expect(snapB.documents).toHaveLength(1);
});
});

View File

@@ -0,0 +1,75 @@
/**
* Unit tests for `resolveSourceLabel()`. Locks the precedence order
* described in workplan CE-WP-0004-T02:
* title → metadata.filename → uri → id.
*/
import { describe, expect, it } from "vitest";
import { resolveSourceLabel } from "./citation-card-source";
import type { Document } from "./document";
import type { DocumentId } from "./ids";
function makeDoc(partial: Partial<Document>): Document {
return {
id: "doc_test" as DocumentId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...partial,
};
}
describe("resolveSourceLabel()", () => {
it("prefers document.title when set", () => {
expect(
resolveSourceLabel(
makeDoc({
title: "Order from 2024",
uri: "https://example.test/o.pdf",
metadata: { filename: "fallback.pdf" },
}),
),
).toBe("Order from 2024");
});
it("falls back to metadata.filename when title is missing", () => {
expect(
resolveSourceLabel(
makeDoc({
uri: "https://example.test/o.pdf",
metadata: { filename: "fallback.pdf" },
}),
),
).toBe("fallback.pdf");
});
it("falls back to uri when title and filename are missing", () => {
expect(
resolveSourceLabel(makeDoc({ uri: "https://example.test/o.pdf" })),
).toBe("https://example.test/o.pdf");
});
it("falls back to id when everything else is missing", () => {
expect(resolveSourceLabel(makeDoc({}))).toBe("doc_test");
});
it("treats whitespace-only strings as empty", () => {
expect(
resolveSourceLabel(
makeDoc({ title: " ", uri: "https://example.test/o.pdf" }),
),
).toBe("https://example.test/o.pdf");
});
it("ignores non-string metadata.filename", () => {
expect(
resolveSourceLabel(
makeDoc({
metadata: { filename: 42 },
uri: "https://example.test/o.pdf",
}),
),
).toBe("https://example.test/o.pdf");
});
});

View File

@@ -0,0 +1,26 @@
/**
* Source-label resolution for citation cards.
*
* Per workplan CE-WP-0004-T02: the label rendered after the quote is
* document.title → document.metadata.filename → document.uri → document.id
* — the first non-empty value wins. Renderers must call this helper so
* the two output formats (Markdown, HTML) stay consistent.
*/
import type { Document } from "./document";
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveSourceLabel(document: Document): string {
const title = nonEmptyString(document.title);
if (title) return title;
const filename = nonEmptyString(document.metadata?.["filename"]);
if (filename) return filename;
const uri = nonEmptyString(document.uri);
if (uri) return uri;
return document.id;
}

View File

@@ -0,0 +1,35 @@
/**
* CitationCard — a presentable rendering of an EvidenceItem.
*
* Implements `wiki/ArchitectureOverview.md` §4.7. Stored or transient,
* always derived from an `EvidenceItem` + its `Document` + the first
* referenced `Annotation`. The renderers in `engine/rendering/` produce
* the string forms (`format: "markdown" | "html"`); the
* `"web-component"` format is reserved for a later workplan (see §14.2).
*
* `id` and `evidenceItemId` use the branded ID types from `./ids` so they
* cannot be swapped at compile time, even though `ArchitectureOverview.md`
* §4.7 shows them as bare `string`s. The brand is invisible at runtime.
*/
import type { CitationCardId, EvidenceItemId } from "./ids";
export type CitationCardFormat = "html" | "markdown" | "web-component";
export interface CitationCard {
readonly id: CitationCardId;
readonly evidenceItemId: EvidenceItemId;
/** Verbatim quote rendered into the card body. */
readonly quote: string;
/**
* Human-readable source label. Resolution order is
* `document.title` → `filename from metadata` → `document.uri` →
* the document id. Renderers should call `resolveSourceLabel()` from
* `./citation-card-source.ts` rather than computing this inline.
*/
readonly sourceLabel: string;
readonly commentary?: string;
/** Deep link to reopen the source context — see `./open-context-url.ts`. */
readonly openContextUrl?: string;
readonly format: CitationCardFormat;
}

View File

@@ -19,6 +19,7 @@ export type EvidenceSetId = Brand<string, "EvidenceSetId">;
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
export type CitationCardId = Brand<string, "CitationCardId">;
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
export type SessionId = Brand<string, "SessionId">;
export type IdKindMap = {
document: DocumentId;
@@ -29,6 +30,7 @@ export type IdKindMap = {
"evidence-link": EvidenceLinkId;
"citation-card": CitationCardId;
"citation-recovery": CitationRecoveryAttemptId;
session: SessionId;
};
export type IdKind = keyof IdKindMap;
@@ -42,6 +44,7 @@ const PREFIXES: Record<IdKind, string> = {
"evidence-link": "evlink",
"citation-card": "card",
"citation-recovery": "crec",
session: "sess",
};
/**

View File

@@ -5,4 +5,9 @@ export * from "./annotation";
export * from "./evidence";
export * from "./evidence-link";
export * from "./evidence-set";
export * from "./citation-card";
export * from "./citation-card-source";
export * from "./open-context-url";
export * from "./session";
export * from "./session-archive";
export { normalize, NORMALIZE_VERSION } from "./text/normalize";

View File

@@ -0,0 +1,32 @@
/**
* Unit tests for `openContextUrl()`. Locks the shape from
* `wiki/ArchitectureOverview.md` §14.3.
*/
import { describe, expect, it } from "vitest";
import type { AnnotationId, DocumentId } from "./ids";
import { openContextUrl } from "./open-context-url";
const DOC = "doc_abc-123" as DocumentId;
const ANN = "ann_def-456" as AnnotationId;
describe("openContextUrl()", () => {
it("produces the canonical /viewer?document=…&annotation=… shape", () => {
expect(openContextUrl({ documentId: DOC, annotationId: ANN })).toBe(
"/viewer?document=doc_abc-123&annotation=ann_def-456",
);
});
it("percent-encodes reserved characters in ids", () => {
const ugly = "doc with spaces&=#" as DocumentId;
const url = openContextUrl({ documentId: ugly, annotationId: ANN });
expect(url).toBe(
"/viewer?document=doc%20with%20spaces%26%3D%23&annotation=ann_def-456",
);
// round-trip
const params = new URL(url, "https://example.test").searchParams;
expect(params.get("document")).toBe("doc with spaces&=#");
expect(params.get("annotation")).toBe("ann_def-456");
});
});

View File

@@ -0,0 +1,36 @@
/**
* Open-context URL — the canonical deep link that lets a citation card
* reopen the source context that backs it.
*
* Implements `wiki/ArchitectureOverview.md` §14.3. The URL shape is:
*
* /viewer?document=<documentId>&annotation=<annotationId>
*
* Both ids are mandatory. The annotation alone is insufficient because
* the router needs the document id to mount the right viewer adapter
* before resolving the annotation's selectors.
*
* This convention is stable across persistence modes — when the
* in-memory engine is replaced by a real backend (ADR-0005), the same
* URL shape is expected to still resolve.
*/
import type { AnnotationId, DocumentId } from "./ids";
export interface OpenContextUrlInput {
readonly documentId: DocumentId;
readonly annotationId: AnnotationId;
}
/**
* Build the deep link for a given (document, annotation) pair.
*
* Query-string values are percent-encoded via `encodeURIComponent` so that
* any future id scheme containing reserved characters (`&`, `=`, `#`) still
* round-trips.
*/
export function openContextUrl(input: OpenContextUrlInput): string {
const docPart = encodeURIComponent(input.documentId);
const annPart = encodeURIComponent(input.annotationId);
return `/viewer?document=${docPart}&annotation=${annPart}`;
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { DocumentId, SessionId } from "./ids";
import {
parseSessionArchiveManifest,
SESSION_ARCHIVE_SCHEMA_VERSION,
SessionArchiveParseError,
type SessionArchiveManifest,
} from "./session-archive";
function validManifest(): SessionArchiveManifest {
return {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt: "2026-05-25T00:00:00.000Z",
session: {
id: "sess_abc" as SessionId,
name: "Demo",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
engine: {
version: 1,
documents: [],
representations: [],
annotations: [],
evidenceItems: [],
},
documentBindings: [
{
documentId: "doc_abc" as DocumentId,
filename: "demo.pdf",
fingerprint: "abc123",
},
],
};
}
describe("parseSessionArchiveManifest", () => {
it("round-trips a valid manifest through JSON.stringify + parse", () => {
const m = validManifest();
const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m)));
expect(round).toEqual(m);
});
it("rejects an unsupported schemaVersion", () => {
const m = { ...validManifest(), schemaVersion: 999 as unknown };
expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError);
expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/);
});
it("rejects a missing required top-level field", () => {
const m = validManifest();
const broken = { ...m, exportedAt: undefined as unknown };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/);
});
it("rejects a malformed session sub-object", () => {
const m = validManifest();
const broken = { ...m, session: { ...m.session, id: 12345 as unknown } };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/);
});
it("rejects a malformed engine snapshot", () => {
const m = validManifest();
const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/);
});
it("rejects a non-array documentBindings", () => {
const m = validManifest();
const broken = { ...m, documentBindings: "nope" as unknown };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/);
});
it("rejects a malformed documentBindings entry", () => {
const m = validManifest();
const broken = {
...m,
documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[],
};
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/);
});
it("rejects a non-object root", () => {
expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/);
expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/);
});
});

View File

@@ -0,0 +1,150 @@
/**
* SessionArchiveManifest — JSON contract for `manifest.json` inside a
* session ZIP archive.
*
* Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md`
* for the authoritative spec. This module is the executable contract:
* `parseSessionArchiveManifest` validates an `unknown` JSON value and
* narrows it to `SessionArchiveManifest` or throws with a useful
* message.
*
* The `engine` field re-uses the in-memory `EngineSnapshot` shape so
* the in-memory ↔ archive round-trip stays a one-way conversion.
* Only minimal structural validation runs here; the engine helpers
* (`restoreSnapshot`) handle deeper validation when actually
* hydrating an engine.
*/
import type { DocumentId, SessionId } from "./ids";
export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const;
export interface SessionArchiveSessionRecord {
readonly id: SessionId;
readonly name: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface SessionArchiveDocumentBinding {
readonly documentId: DocumentId;
readonly filename: string;
readonly fingerprint: string;
}
/**
* Mirror of `EngineSnapshot` — kept loose here (record of unknown) to
* avoid the cross-package dependency back into `@engine`. The engine
* persistence layer owns the authoritative shape.
*/
export interface SessionArchiveEngineSnapshot {
readonly version: number;
readonly documents: readonly unknown[];
readonly representations: readonly unknown[];
readonly annotations: readonly unknown[];
readonly evidenceItems: readonly unknown[];
}
export interface SessionArchiveManifest {
readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION;
readonly exportedAt: string;
readonly session: SessionArchiveSessionRecord;
readonly engine: SessionArchiveEngineSnapshot;
readonly documentBindings: readonly SessionArchiveDocumentBinding[];
}
export class SessionArchiveParseError extends Error {
constructor(message: string) {
super(`SessionArchiveManifest parse failed: ${message}`);
this.name = "SessionArchiveParseError";
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertString(value: unknown, field: string): string {
if (typeof value !== "string") {
throw new SessionArchiveParseError(`field "${field}" must be a string`);
}
return value;
}
function assertObject(value: unknown, field: string): Record<string, unknown> {
if (!isObject(value)) {
throw new SessionArchiveParseError(`field "${field}" must be an object`);
}
return value;
}
function assertArray(value: unknown, field: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new SessionArchiveParseError(`field "${field}" must be an array`);
}
return value;
}
function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord {
const obj = assertObject(raw, "session");
return {
id: assertString(obj.id, "session.id") as SessionId,
name: assertString(obj.name, "session.name"),
createdAt: assertString(obj.createdAt, "session.createdAt"),
updatedAt: assertString(obj.updatedAt, "session.updatedAt"),
};
}
function parseDocumentBinding(
raw: unknown,
index: number,
): SessionArchiveDocumentBinding {
const obj = assertObject(raw, `documentBindings[${index}]`);
return {
documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId,
filename: assertString(obj.filename, `documentBindings[${index}].filename`),
fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`),
};
}
function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot {
const obj = assertObject(raw, "engine");
const version = obj.version;
if (typeof version !== "number") {
throw new SessionArchiveParseError(`field "engine.version" must be a number`);
}
const documents = assertArray(obj.documents, "engine.documents");
const representations = assertArray(obj.representations, "engine.representations");
const annotations = assertArray(obj.annotations, "engine.annotations");
const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems");
return {
version,
documents,
representations,
annotations,
evidenceItems,
};
}
export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest {
const obj = assertObject(raw, "manifest");
const schemaVersion = obj.schemaVersion;
if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) {
throw new SessionArchiveParseError(
`unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`,
);
}
const exportedAt = assertString(obj.exportedAt, "exportedAt");
const session = parseSessionRecord(obj.session);
const engine = parseEngineSnapshot(obj.engine);
const documentBindings = assertArray(obj.documentBindings, "documentBindings").map(
(entry, i) => parseDocumentBinding(entry, i),
);
return {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt,
session,
engine,
documentBindings,
};
}

26
src/shared/session.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* `Session` — a user-named workspace that owns one engine snapshot.
*
* Sessions partition the demo app: each one holds its own documents,
* annotations, evidence items, and links. Membership is implicit — a
* session "owns" whatever lives in its engine snapshot. The session
* record itself only carries the human metadata (name, timestamps) and
* the branded id used as a key in `localStorage` and the ZIP archive
* (see ADR-0008).
*
* The id is opaque (`sess_<uuid>` per `ids.ts`). The name is the human
* label; uniqueness is enforced by the `SessionService` on create and
* rename. Names are *trimmed* before storage so a leading/trailing
* space does not let two sessions coexist with effectively the same
* label.
*/
import type { SessionId } from "./ids";
export interface Session {
readonly id: SessionId;
readonly name: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly lastOpenedAt?: string;
}

View File

@@ -6,3 +6,13 @@ export {
} from "./pdf/ingest";
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
export { fingerprintBytes } from "./pdf/fingerprint";
export {
createPdfByteStore,
type CreatePdfByteStoreOptions,
type PdfByteRecord,
type PdfByteStore,
} from "./pdf/byte-store";
export {
ingestPdfFromFile,
type IngestPdfFromFileOptions,
} from "./pdf/upload";

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentId } from "@shared/ids";
import { createPdfByteStore } from "./byte-store";
function stubUrlHelpers() {
let counter = 0;
const created: string[] = [];
const revoked: string[] = [];
const createObjectURL = vi.fn(() => {
const url = `blob:stub-${++counter}`;
created.push(url);
return url;
});
const revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
return { createObjectURL, revokeObjectURL, created, revoked };
}
describe("PdfByteStore", () => {
it("put / get round-trips bytes and exposes a blob URL", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
const record = store.put("doc_a" as DocumentId, bytes);
expect(record.blobUrl).toBe("blob:stub-1");
expect(store.get("doc_a" as DocumentId)?.bytes).toBe(bytes);
expect(store.has("doc_a" as DocumentId)).toBe(true);
expect(store.list()).toEqual(["doc_a"]);
expect(store.size()).toBe(4);
});
it("put replaces an existing entry and revokes the old URL", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const id = "doc_a" as DocumentId;
const first = store.put(id, new Uint8Array([1, 2]));
const second = store.put(id, new Uint8Array([3, 4, 5]));
expect(helpers.revoked).toEqual([first.blobUrl]);
expect(store.get(id)?.bytes).toHaveLength(3);
expect(second.blobUrl).not.toBe(first.blobUrl);
});
it("delete revokes the blob URL exactly once and is idempotent", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const id = "doc_a" as DocumentId;
const record = store.put(id, new Uint8Array([1, 2, 3]));
expect(store.delete(id)).toBe(true);
expect(helpers.revoked).toEqual([record.blobUrl]);
expect(store.delete(id)).toBe(false);
// No additional revoke calls on the second delete.
expect(helpers.revoked).toHaveLength(1);
expect(store.get(id)).toBeNull();
expect(store.has(id)).toBe(false);
});
it("clear revokes every URL and empties the store", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const a = store.put("doc_a" as DocumentId, new Uint8Array([1]));
const b = store.put("doc_b" as DocumentId, new Uint8Array([2]));
store.clear();
expect(helpers.revoked.sort()).toEqual([a.blobUrl, b.blobUrl].sort());
expect(store.list()).toEqual([]);
expect(store.size()).toBe(0);
});
it("uses URL.createObjectURL by default when no override is supplied", () => {
const createObjectURL = vi.fn(() => "blob:built-in");
const revokeObjectURL = vi.fn();
const originalURL = globalThis.URL;
// happy-dom's URL has createObjectURL; node sometimes does not. Stub it.
Object.defineProperty(globalThis, "URL", {
configurable: true,
writable: true,
value: Object.assign(Object.create(originalURL.prototype as object), {
createObjectURL,
revokeObjectURL,
}),
});
try {
const store = createPdfByteStore();
const rec = store.put("doc_z" as DocumentId, new Uint8Array([9]));
expect(rec.blobUrl).toBe("blob:built-in");
expect(createObjectURL).toHaveBeenCalledTimes(1);
store.delete("doc_z" as DocumentId);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:built-in");
} finally {
Object.defineProperty(globalThis, "URL", {
configurable: true,
writable: true,
value: originalURL,
});
}
});
});

View File

@@ -0,0 +1,113 @@
/**
* `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by
* `DocumentId`.
*
* CE-WP-0005 stores uploaded PDFs in memory only (per the workplan
* scoping decision). Bytes survive within a tab session; reloading the
* page loses them unless the user exported a ZIP. Re-importing the ZIP
* restores them.
*
* One store instance per active session. The session-management layer
* is responsible for swapping the active store when the user switches
* sessions. The store also owns a small registry of issued `blob:`
* URLs so it can revoke them on delete/clear — no cross-cutting
* cleanup at the app layer.
*/
import type { DocumentId } from "@shared/ids";
export interface PdfByteRecord {
readonly bytes: Uint8Array;
/** A `blob:` URL the viewer can consume directly. */
readonly blobUrl: string;
}
export interface PdfByteStore {
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
get(documentId: DocumentId): PdfByteRecord | null;
has(documentId: DocumentId): boolean;
delete(documentId: DocumentId): boolean;
list(): readonly DocumentId[];
/** Revoke every blob URL and clear the store. */
clear(): void;
/** Total bytes currently held — useful for UI dashboards. */
size(): number;
}
export interface CreatePdfByteStoreOptions {
/**
* Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in
* environments that have it; tests can inject a deterministic stub.
*/
readonly createObjectURL?: (blob: Blob) => string;
/** Revoke a URL previously minted by `createObjectURL`. */
readonly revokeObjectURL?: (url: string) => void;
}
export function createPdfByteStore(
options: CreatePdfByteStoreOptions = {},
): PdfByteStore {
const createUrl =
options.createObjectURL ??
((blob: Blob) => {
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error(
"createPdfByteStore: URL.createObjectURL not available — inject a stub via options",
);
}
return URL.createObjectURL(blob);
});
const revokeUrl =
options.revokeObjectURL ??
((url: string) => {
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
});
const records = new Map<DocumentId, PdfByteRecord>();
return {
put(documentId, bytes) {
// Replace previous record (revoking the prior URL) if any.
const prior = records.get(documentId);
if (prior) revokeUrl(prior.blobUrl);
// Cast: Blob() does accept Uint8Array at runtime, but TS narrows the
// buffer type to ArrayBufferLike (could be SharedArrayBuffer) and
// refuses without help. The bytes here always come from a fresh
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
const blob = new Blob([bytes as unknown as ArrayBuffer], {
type: "application/pdf",
});
const blobUrl = createUrl(blob);
const record: PdfByteRecord = { bytes, blobUrl };
records.set(documentId, record);
return record;
},
get(documentId) {
return records.get(documentId) ?? null;
},
has(documentId) {
return records.has(documentId);
},
delete(documentId) {
const record = records.get(documentId);
if (!record) return false;
revokeUrl(record.blobUrl);
records.delete(documentId);
return true;
},
list() {
return [...records.keys()];
},
clear() {
for (const record of records.values()) revokeUrl(record.blobUrl);
records.clear();
},
size() {
let total = 0;
for (const r of records.values()) total += r.bytes.byteLength;
return total;
},
};
}

View File

@@ -0,0 +1,91 @@
/**
* `ingestPdfFromFile` end-to-end: pipes a fixture PDF through the
* upload path, asserts the byte store keeps the bytes and the document
* record carries the minted `blob:` URL.
*/
import { readFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import { createPdfByteStore } from "./byte-store";
import { ingestPdfFromFile } from "./upload";
const FIXTURE_PATH = new URL(
"../../../fixtures/pdfs/Fristsetzung zur Bezifferung GÜ an Gegenseite 3 Wochen.pdf",
import.meta.url,
);
async function fixtureBytes(): Promise<Uint8Array> {
return new Uint8Array(await readFile(FIXTURE_PATH));
}
class FakeFile {
readonly name: string;
private readonly bytes: Uint8Array;
constructor(bytes: Uint8Array, name: string) {
this.bytes = bytes;
this.name = name;
}
async arrayBuffer(): Promise<ArrayBuffer> {
const out = new ArrayBuffer(this.bytes.byteLength);
new Uint8Array(out).set(this.bytes);
return out;
}
}
describe("ingestPdfFromFile", () => {
it("round-trips a fixture PDF through ingest + byte store + blob URL", async () => {
const bytes = await fixtureBytes();
const file = new FakeFile(bytes, "demo.pdf") as unknown as File;
let counter = 0;
const store = createPdfByteStore({
createObjectURL: () => `blob:upload-stub-${++counter}`,
revokeObjectURL: () => {},
});
const { document, representation } = await ingestPdfFromFile(file, store);
// Bytes are stored, retrievable by document id.
const stored = store.get(document.id);
expect(stored).not.toBeNull();
expect(stored!.bytes.byteLength).toBe(bytes.byteLength);
// Document carries the blob URL minted by the store.
expect(document.uri).toBe(`blob:upload-stub-${counter}`);
expect(document.title).toBe("demo.pdf");
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
// Representation is the standard pdf-text one.
expect(representation.representationType).toBe("pdf-text");
expect((representation.canonicalText ?? "").length).toBeGreaterThan(0);
}, 30_000);
it("falls through to ingestPdf with no filename when given a plain Blob", async () => {
const bytes = await fixtureBytes();
const blob = {
async arrayBuffer() {
const out = new ArrayBuffer(bytes.byteLength);
new Uint8Array(out).set(bytes);
return out;
},
} as Blob;
const store = createPdfByteStore({
createObjectURL: () => "blob:no-name",
revokeObjectURL: () => {},
});
const { document } = await ingestPdfFromFile(blob, store);
expect(document.title).toBeUndefined();
expect(document.uri).toBe("blob:no-name");
}, 30_000);
it("explicit title option overrides the filename", async () => {
const bytes = await fixtureBytes();
const file = new FakeFile(bytes, "anonymous-name.pdf") as unknown as File;
const store = createPdfByteStore({
createObjectURL: vi.fn(() => "blob:override"),
revokeObjectURL: vi.fn(),
});
const { document } = await ingestPdfFromFile(file, store, { title: "Custom" });
expect(document.title).toBe("Custom");
}, 30_000);
});

45
src/source/pdf/upload.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Upload-side ingest path.
*
* The fixture-loading path in `App.tsx` fetches a known URL and calls
* `ingestPdf` directly; that path stays untouched for the optional
* "Sample sessions" quick-start. Uploaded files flow through here
* instead:
*
* 1. Read `file.arrayBuffer()` once into a `Uint8Array`.
* 2. Run the existing `ingestPdf(bytes, { filename })` pipeline to
* produce `{document, representation}`.
* 3. Push the bytes into the per-session `PdfByteStore`, which mints
* a `blob:` URL and stamps it onto `document.uri` so the viewer
* adapter can mount the PDF directly.
* 4. Hand the engine inputs back to the caller, which wires them via
* `engine.documents.register(...)`.
*
* Keeping URL-minting inside the byte store (rather than at the call
* site) means there is exactly one place that creates `blob:` URLs and
* exactly one place that revokes them.
*/
import { ingestPdf, type IngestPdfResult } from "./ingest";
import type { PdfByteStore } from "./byte-store";
export interface IngestPdfFromFileOptions {
/** Override the filename used as the document title. */
readonly title?: string;
}
export async function ingestPdfFromFile(
file: File | Blob,
store: PdfByteStore,
options: IngestPdfFromFileOptions = {},
): Promise<IngestPdfResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
const ingested = await ingestPdf(bytes, {
...(filename !== undefined ? { filename } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
});
const record = store.put(ingested.document.id, bytes);
const document = { ...ingested.document, uri: record.blobUrl };
return { document, representation: ingested.representation };
}

View File

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

View File

@@ -1,71 +1,76 @@
/**
* CollectionList — the left pane.
*
* Lists the fixture corpus (the MVP stand-in for a real document collection).
* Clicking a fixture fetches the bytes, runs `ingestPdf` (PDF.js extraction
* + fingerprint + canonical text), registers the result with the engine
* (emitting §4 events), and activates it as the current document.
* 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.
*
* Per CE-WP-0002-T06, the loaded fixture set is hard-wired to
* `fixtures/pdfs/manifest.json`. Real collections arrive in a later
* workplan.
* Fixture-driven quick-start lives in
* `src/app/sessions/SampleSessions.tsx` and is no longer the default.
*/
import { useCallback, useState } from "react";
import { ingestPdf } from "@source/index";
import { useEngine, useActiveDocumentId } from "./EngineContext";
import type { DocumentId } from "@shared/ids";
import manifest from "../../fixtures/pdfs/manifest.json";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import type { ReactNode } from "react";
interface Fixture {
id: string;
filename: string;
description: string;
page_count: number;
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;
}
const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures;
export function CollectionList() {
export function CollectionList({ upload, title }: CollectionListProps) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { id: activeId, setId } = useActiveDocumentId();
const [loadingFixtureId, setLoadingFixtureId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Remember which fixture-id maps to which loaded documentId so re-clicking
// a fixture activates the existing engine record rather than re-ingesting.
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
const handleLoad = useCallback(
async (fixture: Fixture) => {
setError(null);
const importedTick = useEngineEventTick("DocumentImported");
const removedTick = useEngineEventTick("DocumentRemoved");
const revision = useEngineRevision();
const existing = byFixture[fixture.id];
if (existing) {
setId(existing);
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;
}
setLoadingFixtureId(fixture.id);
try {
const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`fetch ${url}${response.status}`);
}
const buffer = await response.arrayBuffer();
const { document, representation } = await ingestPdf(new Uint8Array(buffer), {
filename: fixture.filename,
});
engine.documents.register({ document, representation });
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
setId(document.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingFixtureId(null);
}
// 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);
},
[byFixture, engine, setId],
[activeId, byteStore, engine, pendingDeleteId, setId],
);
return (
@@ -78,44 +83,66 @@ export function CollectionList() {
flex: "0 0 280px",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Collection</h2>
<h2 style={{ marginTop: 0, fontSize: 16 }}>
{title ?? "Collection"}
</h2>
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
{FIXTURES.length} fixture PDF{FIXTURES.length === 1 ? "" : "s"}
{documents.length} document{documents.length === 1 ? "" : "s"}
</p>
{error && (
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
{error}
{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 style={{ listStyle: "none", padding: 0, margin: 0 }}>
{FIXTURES.map((f) => {
const isLoading = loadingFixtureId === f.id;
const documentId = byFixture[f.id];
const isActive = documentId !== undefined && documentId === activeId;
<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={f.id} style={{ marginBottom: 6 }}>
<button
onClick={() => {
void handleLoad(f);
}}
disabled={isLoading}
<li key={doc.id} style={{ marginBottom: 6 }}>
<div
style={{
display: "block",
width: "100%",
textAlign: "left",
background: isActive ? "#e8f0ff" : "white",
border: "1px solid #ccc",
padding: 6,
cursor: isLoading ? "wait" : "pointer",
background: isActive ? "#e8f0ff" : "white",
display: "flex",
flexDirection: "column",
fontSize: 12,
}}
data-testid={`collection-item-${doc.id}`}
>
<div style={{ fontWeight: 600 }}>{f.id}</div>
<div style={{ color: "#666", fontSize: 11 }}>
{f.page_count} page{f.page_count === 1 ? "" : "s"}
{isLoading ? " · loading…" : isActive ? " · open" : ""}
<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>
</button>
</div>
</li>
);
})}
@@ -123,3 +150,14 @@ export function CollectionList() {
</aside>
);
}
const openButtonStyle: CSSProperties = {
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
cursor: "pointer",
fontSize: 12,
};

View File

@@ -21,24 +21,39 @@ import {
type ReactNode,
} from "react";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { AnnotationId, DocumentId } from "@shared/ids";
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 "@anchor/index";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { useContext as useReactContext } from "react";
import { SessionInternalContext } from "./SessionContextInternal";
/**
* localStorage keys for the engine snapshot and the UI's "what was open"
* pointer. ADR-0005 frames both as deliberately temporary — real
* persistence later.
* 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 STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
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.
@@ -51,6 +66,7 @@ export interface PendingSelection {
interface EngineContextValue {
readonly engine: Engine;
readonly byteStore: PdfByteStore;
readonly activeDocumentId: DocumentId | null;
setActiveDocumentId(id: DocumentId | null): void;
readonly pendingSelection: PendingSelection | null;
@@ -60,6 +76,13 @@ interface EngineContextValue {
* 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);
@@ -68,31 +91,67 @@ 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 }: EngineProviderProps) {
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: STORAGE_KEY });
const result = restoreFromStorage(engine, { key: snapshotKey });
if (result.restored) {
const saved = globalThis.localStorage.getItem(ACTIVE_KEY);
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: STORAGE_KEY });
}, [engine, injected]);
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.
@@ -100,11 +159,11 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
if (activeDocumentId) {
globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId);
globalThis.localStorage.setItem(activeDocKey, activeDocumentId);
} else {
globalThis.localStorage.removeItem(ACTIVE_KEY);
globalThis.localStorage.removeItem(activeDocKey);
}
}, [activeDocumentId, injected]);
}, [activeDocumentId, injected, activeDocKey]);
// Switching the active document discards any pending selection — it
// belongs to the previous document's viewer state.
@@ -121,6 +180,7 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
const value = useMemo<EngineContextValue>(
() => ({
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
@@ -128,8 +188,18 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
scrollToAnnotationId: scrollState.id,
scrollVersion: scrollState.version,
scrollToAnnotation,
engineRevision,
}),
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
[
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
scrollState,
scrollToAnnotation,
engineRevision,
],
);
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
@@ -141,6 +211,18 @@ export function useEngine(): Engine {
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;

View File

@@ -0,0 +1,210 @@
/**
* 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");
});
});
// Silence "unused" warnings for the test-scope captures we kept for
// debugging — TypeScript would otherwise complain.
void lastEngine;
void activeDocumentId;

View File

@@ -6,40 +6,126 @@
* `EvidenceItemActivated` via the engine, which T08 will translate into a
* scroll-to-passage in the viewer.
*
* T06 scope: read-only display + activation event. Item creation lives in
* T07; the click-to-reopen integration lives in T08.
* CE-WP-0004-T04 added: a per-item Export popover (Copy as Markdown /
* Copy as HTML), a transient toast confirming the copy, and the
* Cmd/Ctrl+Shift+C keyboard shortcut that exports the currently-active
* evidence as Markdown.
*/
import { useMemo } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import type { EvidenceItem } from "@shared/evidence";
import type { EvidenceItemId } from "@shared/ids";
import {
useActiveDocument,
useEngine,
useEngineEventTick,
useEngineRevision,
useLastActivatedEvidence,
useScrollToAnnotation,
} from "./EngineContext";
import {
useExportEvidence,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
const TOAST_TIMEOUT_MS = 2000;
export interface EvidenceSidebarProps {
onActivate?(item: EvidenceItem): void;
}
interface ToastState {
readonly message: string;
readonly tone: "success" | "error";
/** Bumps on every new toast so timers don't dismiss the *next* toast. */
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";
}
export function EvidenceSidebar(props: EvidenceSidebarProps) {
const engine = useEngine();
const { document } = useActiveDocument();
const { scrollTo } = useScrollToAnnotation();
const activeId = useLastActivatedEvidence();
const { exportItem } = useExportEvidence();
// Refresh the list when items are created or updated. The tick values are
// included in the memo deps below so the list re-resolves on each event.
const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated");
const revision = useEngineRevision();
const items = useMemo<readonly EvidenceItem[]>(() => {
if (!document) return [];
return engine.evidence.listByDocument(document.id);
// createTick / updateTick are read here purely as memo invalidators.
}, [document, engine, createTick, updateTick]);
}, [document, engine, createTick, updateTick, revision]);
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
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]);
return (
<aside
@@ -50,6 +136,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
overflow: "auto",
flex: "0 0 320px",
fontFamily: "system-ui, sans-serif",
position: "relative",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Evidence</h2>
@@ -64,42 +151,162 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{items.map((item) => {
const firstAnnotationId = item.annotationIds[0];
const annotation = firstAnnotationId ? engine.annotations.get(firstAnnotationId) : null;
const annotation = firstAnnotationId
? engine.annotations.get(firstAnnotationId)
: null;
const quote = annotation?.quote ?? "(no quote)";
const isActive = activeId === item.id;
const isExportOpen = openExportFor === item.id;
return (
<li key={item.id} style={{ marginBottom: 8 }}>
<button
onClick={() => {
engine.evidence.activate(item.id, "sidebar");
if (firstAnnotationId) scrollTo(firstAnnotationId);
props.onActivate?.(item);
}}
aria-current={isActive ? "true" : undefined}
<div
style={{
display: "block",
width: "100%",
textAlign: "left",
position: "relative",
background: isActive ? "#e8f0ff" : "#fff8d6",
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
padding: 8,
cursor: "pointer",
fontSize: 12,
borderRadius: 2,
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</div>
{item.commentary && (
<div style={{ color: "#333", marginBottom: 4 }}>{item.commentary}</div>
<button
onClick={() => {
engine.evidence.activate(item.id, "sidebar");
if (firstAnnotationId) scrollTo(firstAnnotationId);
props.onActivate?.(item);
}}
aria-current={isActive ? "true" : undefined}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
paddingRight: 80,
cursor: "pointer",
fontSize: 12,
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</div>
{item.commentary && (
<div style={{ color: "#333", marginBottom: 4 }}>
{item.commentary}
</div>
)}
<div style={{ color: "#666", fontSize: 11 }}>
status: {item.status}
</div>
</button>
<button
type="button"
aria-haspopup="menu"
aria-expanded={isExportOpen}
aria-label="Export evidence item"
data-testid={`export-toggle-${item.id}`}
onClick={(e) => {
e.stopPropagation();
setOpenExportFor((current) =>
current === item.id ? null : item.id,
);
}}
style={{
position: "absolute",
top: 6,
right: 6,
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
}}
>
Export
</button>
{isExportOpen && (
<div
role="menu"
data-testid={`export-menu-${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();
setOpenExportFor(null);
await runExport(item, "markdown");
}}
style={menuButtonStyle}
>
Copy as Markdown
</button>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
setOpenExportFor(null);
await runExport(item, "html");
}}
style={menuButtonStyle}
>
Copy as HTML
</button>
</div>
)}
<div style={{ color: "#666", fontSize: 11 }}>status: {item.status}</div>
</button>
</div>
</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>
);
}
const menuButtonStyle: CSSProperties = {
textAlign: "left",
background: "transparent",
border: "none",
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
};

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

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

View File

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

View File

@@ -1,6 +1,12 @@
export { CollectionList } from "./CollectionList";
export { CollectionList, type CollectionListProps } from "./CollectionList";
export { ViewerShell } from "./ViewerShell";
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
export {
useExportEvidence,
type ExportEvidenceApi,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
export { AnnotationToolbar } from "./AnnotationToolbar";
export {
EngineProvider,
@@ -8,8 +14,23 @@ export {
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";

View File

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

View File

@@ -28,8 +28,6 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Selector } from "@shared/selector";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
@@ -90,42 +88,8 @@ const SYNTHETIC_CANONICAL = [
"Trailing prose that comes after the quote.",
].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "synthetic-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: SYNTHETIC_CANONICAL.length,
pageLength: SYNTHETIC_CANONICAL.length,
},
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc, type SeedResult } from "./helpers/seed-session";
// ---------------------------------------------------------------------------
// Helpers
@@ -160,40 +124,40 @@ async function loadApp() {
// ---------------------------------------------------------------------------
describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
let seeded: SeedResult;
beforeEach(() => {
resetViewerSnapshot();
// Each test starts with empty localStorage.
globalThis.localStorage?.clear();
// The fetch isn't reached (ingestPdf is mocked) — but stub it so that
// any accidental call returns gracefully instead of TypeError.
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
seeded = seedSessionWithDoc({
sessionName: "Demo",
documentTitle: FIXTURE.filename,
canonicalText: SYNTHETIC_CANONICAL,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", async () => {
it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", { timeout: 15000 }, async () => {
const user = userEvent.setup();
// Step 1: open the app.
// Step 1: open the app. CE-WP-0005: a pre-seeded session boots
// straight into the active layout (no empty state).
const { unmount } = await loadApp();
expect(screen.getByText("Collection")).toBeTruthy();
expect(screen.queryByTestId("empty-state")).toBeNull();
expect(screen.getByTestId("session-menu-toggle").textContent).toContain("Demo");
// Step 2: pick a fixture.
const fixtureButton = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureButton);
// The mock viewer should have mounted with our test URL.
// Step 2: the fixture doc is pre-seeded into the active session, so
// the viewer mounts automatically — no fixture-button click needed.
await waitFor(() => {
expect(viewerSnapshot.pdfUrl).toBeTruthy();
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
});
expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename);
// Step 3: programmatically inject a selection for the known-good quote.
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
@@ -223,9 +187,11 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
});
const savedAnnotationId = viewerSnapshot.storedAnnotationIds[0]!;
// Snapshot key from EngineContext.STORAGE_KEY — implementation detail
// Per-session snapshot key from CE-WP-0005 — implementation detail
// but worth asserting once at the integration layer.
const stored = globalThis.localStorage?.getItem("citation-evidence:engine-snapshot:v1");
const stored = globalThis.localStorage?.getItem(
`citation-evidence:session:${seeded.sessionId}:engine-snapshot:v1`,
);
expect(stored).toBeTruthy();
// Step 6: reload — unmount and remount the App. The same localStorage is
@@ -237,9 +203,8 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
// The viewer should re-mount automatically because the active document
// was persisted.
await waitFor(() => {
expect(viewerSnapshot.pdfUrl).toBeTruthy();
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
});
expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename);
// The sidebar should show the restored item.
const restoredItem = await screen.findByText(/Important deadline clause/);

View File

@@ -0,0 +1,215 @@
/**
* CE-WP-0004-T05 — end-to-end test of PRD scenario step 10.
*
* Continues the slice-1 scenario verified by CE-WP-0002-T09:
*
* 1-5 (recap from T09): load app → pick fixture → inject selection →
* save evidence item.
* 10. Click Export → Copy as Markdown on the saved evidence item.
* 11. Read the clipboard; assert it contains the quote text, the
* document title, the commentary, and a URL matching the
* `/viewer?document=...&annotation=...` shape.
*
* The Playwright form mentioned in the workplan is approximated with the
* same happy-dom integration setup the rest of the slice-1 tests use
* (see ADR-0004 for the headless-Chromium limitation that motivates
* this trade-off). The viewer and the PDF ingest pipeline are mocked;
* the clipboard is intercepted by patching `Clipboard.prototype.writeText`.
*/
// @vitest-environment happy-dom
import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
interface ViewerProps {
pdfUrl: string;
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
scrollToAnnotationId?: string;
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
}
interface ViewerSnapshot {
pdfUrl: string | null;
onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null;
}
const viewerSnapshot: ViewerSnapshot = {
pdfUrl: null,
onSelectionCaptured: null,
};
vi.mock("@anchor/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (props: ViewerProps) => {
viewerSnapshot.pdfUrl = props.pdfUrl;
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
return <div data-testid="mock-pdf-viewer" />;
};
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
});
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "synthetic-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
kind: "pdf",
text,
page,
rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
};
}
let writeText: ReturnType<typeof vi.fn>;
function installClipboardSpy() {
writeText = vi.fn(async () => undefined);
// happy-dom's Clipboard.writeText lives on the prototype. Patch there
// so the spy intercepts every call without fighting the Navigator
// class's private #clipboard field.
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
async function loadApp() {
const { App } = await import("@app/App");
return render(<App />);
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
beforeEach(() => {
viewerSnapshot.pdfUrl = null;
viewerSnapshot.onSelectionCaptured = null;
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
});
afterEach(() => {
vi.restoreAllMocks();
});
it(
"saves an evidence item, exports it as Markdown, and writes the citation card to the clipboard",
{ timeout: 15000 },
async () => {
const user = userEvent.setup();
await loadApp();
installClipboardSpy();
// --- Steps 1-5 (recap from CE-WP-0002-T09) -------------------------
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
await act(async () => {
viewerSnapshot.onSelectionCaptured!(
syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
[{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
);
});
await user.type(
screen.getByPlaceholderText(/Add a one-line comment/),
"Export E2E commentary",
);
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await screen.findByText(/Export E2E commentary/);
// --- Step 10: click Export → Copy as Markdown ----------------------
// Reinstall after render so happy-dom's prototype reset (observed
// between beforeEach and the actual click) doesn't swallow the spy.
installClipboardSpy();
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(
await screen.findByRole("menuitem", { name: "Copy as Markdown" }),
);
// --- Step 11: clipboard contains the full citation card ------------
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
// Quote text from the manifest fixture.
expect(written).toContain(FIXTURE.known_good_quote);
// Document title — App's load path uses the fixture filename as
// the document title (via ingestPdf options.filename).
expect(written).toContain(FIXTURE.filename);
// Commentary entered above.
expect(written).toContain("Export E2E commentary");
// Open-context URL of the canonical shape. The doc and annotation
// ids are randomly minted; assert via regex.
expect(written).toMatch(/\(\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+\)/);
// Success toast surfaces to the user.
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("success");
expect(toast.textContent).toContain("Copied as Markdown");
},
);
});

View File

@@ -20,8 +20,6 @@ import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
@@ -73,42 +71,8 @@ const SYNTHETIC_CANONICAL = [
"Post quote.",
].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "synthetic-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: SYNTHETIC_CANONICAL.length,
pageLength: SYNTHETIC_CANONICAL.length,
},
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc } from "./helpers/seed-session";
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
@@ -136,15 +100,14 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
beforeEach(() => {
resetSnapshot();
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
seedSessionWithDoc({
sessionName: "T06-cycling",
documentTitle: FIXTURE.filename,
canonicalText: SYNTHETIC_CANONICAL,
});
});
afterEach(() => {
@@ -157,8 +120,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
await loadApp();
// --- Review mode: create an evidence item via the captured-selection flow.
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
// CE-WP-0005: doc pre-seeded — skip fixture click.
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
await act(async () => {
viewerSnapshot.onSelectionCaptured!(

View File

@@ -22,8 +22,7 @@ import { act, 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 { AnnotationId, DocumentId, RepresentationId } from "@shared/ids";
import type { AnnotationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
@@ -55,38 +54,8 @@ vi.mock("@anchor/index", async (importOriginal) => {
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
const synthetic = "Synthetic canonical text for the form-link test.";
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "synthetic-fingerprint-for-test",
canonicalText: synthetic,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: synthetic.length, pageLength: synthetic.length },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc } from "./helpers/seed-session";
async function loadApp() {
const { App } = await import("@app/App");
@@ -96,12 +65,6 @@ async function loadApp() {
describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => {
beforeEach(() => {
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
// Forms mode is hash-driven; make sure we start clean.
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
@@ -114,17 +77,19 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)"
});
it("stages an evidence item then links it to the clicked field", async () => {
seedSessionWithDoc({
sessionName: "T05-link",
documentTitle: FIXTURE.filename,
canonicalText: "Synthetic canonical text for the form-link test.",
});
const user = userEvent.setup();
await loadApp();
// Switch to Forms via the top-bar button.
await user.click(screen.getByRole("button", { name: "Forms" }));
// The collection list is in the Forms layout too.
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
// Wait for the fixture to load and the form to appear.
// CE-WP-0005: doc is pre-seeded into the active session.
// Wait for the form to appear.
await waitFor(() => {
expect(screen.queryByLabelText("Summary of the matter")).not.toBeNull();
});
@@ -170,12 +135,12 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)"
expect(screen.getByTestId("field-summary-chip").textContent).toMatch(/1 evidence/);
});
it("starts in Review mode by default and switches to Forms via hash", async () => {
it("starts in the empty state when no session is active (CE-WP-0005 default)", async () => {
await loadApp();
expect(screen.getByText("Collection")).toBeTruthy();
// Review pane's no-doc-open hint from EvidenceSidebar:
expect(screen.queryByText(/No document open/)).not.toBeNull();
// No demo form rendered yet
// The empty-state landing is what users see now until they create
// a session.
expect(screen.getByTestId("empty-state")).toBeTruthy();
// No demo form rendered yet.
expect(screen.queryByText("Demo evidence-backed form")).toBeNull();
});
});

View File

@@ -27,8 +27,6 @@ import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
@@ -82,37 +80,8 @@ vi.mock("@anchor/index", async (importOriginal) => {
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "synthetic-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc } from "./helpers/seed-session";
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
@@ -135,15 +104,14 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
viewerSnapshot.scrollToAnnotationId = null;
viewerSnapshot.onSelectionCaptured = null;
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
seedSessionWithDoc({
sessionName: "T08-forms",
documentTitle: FIXTURE.filename,
canonicalText: SYNTHETIC_CANONICAL,
});
});
afterEach(() => {
@@ -159,8 +127,7 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
await loadApp();
// Steps 1-4 (CE-WP-0002 setup): create an evidence item in Review mode.
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
// CE-WP-0005: doc pre-seeded into the session — skip fixture click.
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
await act(async () => {
viewerSnapshot.onSelectionCaptured!(
@@ -175,9 +142,10 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await screen.findByText(/Overlay E2E evidence/);
// Step 5: navigate to /forms/demo via the top-bar.
// Step 5: navigate to forms via the top-bar.
await user.click(screen.getByRole("button", { name: "Forms" }));
expect(window.location.hash).toBe("#/forms/demo");
// CE-WP-0005: route is now session-scoped.
expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/);
// Step 6: stage the evidence in the strip, then click the summary
// field to create the link.

View File

@@ -0,0 +1,116 @@
/**
* Test helper: pre-seed `localStorage` with an active session that holds
* one document + one representation, so an integration test can skip
* the empty-state + upload flow and mount straight into a populated
* Review/Forms layout.
*
* Pre-CE-WP-0005 integration tests clicked a "fixture button" in the
* `CollectionList` to load a sample PDF; after the session refactor
* there is no fixture list directly in the active-session UI. These
* legacy tests now call `seedSessionWithDoc` in `beforeEach` instead.
*
* The seed bypasses `SessionService.create()` (which mints a random
* id) so tests can reference the resulting `documentId` /
* `representationId` synchronously, before the app boots.
*/
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
export interface SeedOptions {
readonly sessionName?: string;
readonly documentTitle?: string;
readonly canonicalText: string;
readonly mode?: "review" | "forms";
}
export interface SeedResult {
readonly sessionId: SessionId;
readonly documentId: DocumentId;
readonly representationId: RepresentationId;
}
function rid(prefix: string): string {
return `${prefix}_seed_${Math.random().toString(36).slice(2, 10)}`;
}
export function seedSessionWithDoc(opts: SeedOptions): SeedResult {
const sessionId = rid("sess") as SessionId;
const documentId = rid("doc") as DocumentId;
const representationId = rid("rep") as RepresentationId;
const now = "2026-05-25T00:00:00.000Z";
const session: Session = {
id: sessionId,
name: opts.sessionName ?? "Test Session",
createdAt: now,
updatedAt: now,
lastOpenedAt: now,
};
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(opts.documentTitle ? { title: opts.documentTitle } : {}),
fingerprint: "seed-fingerprint",
createdAt: now,
updatedAt: now,
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "seed-fingerprint",
canonicalText: opts.canonicalText,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: opts.canonicalText.length,
pageLength: opts.canonicalText.length,
},
],
generatedAt: now,
};
const snapshot = {
version: 1,
documents: [document],
representations: [representation],
annotations: [],
evidenceItems: [],
};
const sessionsFile = {
version: 1,
sessions: [session],
activeSessionId: sessionId,
};
localStorage.setItem("citation-evidence:sessions:v1", JSON.stringify(sessionsFile));
localStorage.setItem("citation-evidence:active-session-id:v1", sessionId);
localStorage.setItem(
`citation-evidence:session:${sessionId}:engine-snapshot:v1`,
JSON.stringify(snapshot),
);
localStorage.setItem(
`citation-evidence:session:${sessionId}:active-document-id:v1`,
documentId,
);
// Set the hash so the App routes straight into the session.
const hash =
opts.mode === "forms"
? `#/s/${sessionId}/forms/demo`
: `#/s/${sessionId}`;
history.replaceState(
null,
"",
window.location.pathname + window.location.search + hash,
);
return { sessionId, documentId, representationId };
}

View File

@@ -0,0 +1,350 @@
/**
* CE-WP-0005-T08 — full create → annotate → export → reimport E2E.
*
* Walks the demo loop end-to-end:
*
* 1. Load app → empty state.
* 2. Create session "Demo" via the inline name input.
* 3. Upload a synthetic PDF.
* 4. Inject a selection for the manifest's known-good quote → save
* evidence with a commentary.
* 5. (Sanity) Click Export → Copy as Markdown; assert the clipboard
* payload contains the quote + commentary + openContextUrl.
* 6. Click Export ZIP; capture the Blob via a URL.createObjectURL
* spy.
* 7. Click Import ZIP with the captured Blob; assert merge:
* - one document (deduped by fingerprint)
* - two evidence rows (additive merge)
* 8. Click Export → Copy as Markdown on the merged evidence; assert
* the citation card text matches the original.
*/
// @vitest-environment happy-dom
import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
interface ViewerProps {
pdfUrl: string;
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
scrollToAnnotationId?: string;
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
}
interface ViewerSnapshot {
pdfUrl: string | null;
onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null;
}
const viewerSnapshot: ViewerSnapshot = {
pdfUrl: null,
onSelectionCaptured: null,
};
vi.mock("@anchor/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (props: ViewerProps) => {
viewerSnapshot.pdfUrl = props.pdfUrl;
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
return <div data-testid="mock-pdf-viewer" />;
};
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
});
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
// Bypass real PDF.js extraction. The mock returns a stable fingerprint
// so the merge-on-fingerprint dedup logic actually fires on re-import.
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
let counter = 0;
return {
...original,
ingestPdfFromFile: vi.fn(
async (file: File | Blob, store: import("@source/index").PdfByteStore) => {
counter += 1;
const filename =
"name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf";
const documentId = ("doc_e2e_" + counter) as DocumentId;
const representationId = ("rep_e2e_" + counter) as RepresentationId;
const bytes = new Uint8Array(await file.arrayBuffer());
const record = store.put(documentId, bytes);
const document: Document = {
id: documentId,
mediaType: "application/pdf",
title: filename,
uri: record.blobUrl,
// Stable fingerprint — the importer dedupes by fingerprint, so
// re-importing the export merges into the same doc.
fingerprint: "fp-stable-e2e",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "fp-stable-e2e",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: SYNTHETIC_CANONICAL.length,
pageLength: SYNTHETIC_CANONICAL.length,
},
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
},
),
};
});
// ---------------------------------------------------------------------------
// Test-time intercepts: clipboard, URL.createObjectURL, file-picker input.
// ---------------------------------------------------------------------------
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
kind: "pdf",
text,
page,
rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
};
}
let writeText: ReturnType<typeof vi.fn>;
function installClipboardSpy() {
writeText = vi.fn(async () => undefined);
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
interface UrlInterceptor {
blobs: Blob[];
restore(): void;
}
function installUrlInterceptor(): UrlInterceptor {
const blobs: Blob[] = [];
const origCreate = URL.createObjectURL;
const origRevoke = URL.revokeObjectURL;
let counter = 0;
URL.createObjectURL = ((b: Blob) => {
blobs.push(b);
counter += 1;
return `blob:test-${counter}`;
}) as typeof URL.createObjectURL;
URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL;
return {
blobs,
restore() {
URL.createObjectURL = origCreate;
URL.revokeObjectURL = origRevoke;
},
};
}
interface FilePickerInterceptor {
inputs: HTMLInputElement[];
restore(): void;
}
function installFilePickerInterceptor(): FilePickerInterceptor {
const inputs: HTMLInputElement[] = [];
const origCreate = document.createElement.bind(document);
(document.createElement as unknown) = (tag: string) => {
const el = origCreate(tag);
if (tag === "input") inputs.push(el as HTMLInputElement);
return el;
};
return {
inputs,
restore() {
document.createElement = origCreate;
},
};
}
async function loadApp() {
const { App } = await import("@app/App");
return render(<App />);
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
describe("CE-WP-0005-T08 — full create → annotate → export → reimport E2E", () => {
let url: UrlInterceptor;
let picker: FilePickerInterceptor;
beforeEach(() => {
viewerSnapshot.pdfUrl = null;
viewerSnapshot.onSelectionCaptured = null;
globalThis.localStorage?.clear();
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
url = installUrlInterceptor();
picker = installFilePickerInterceptor();
});
afterEach(() => {
cleanup();
url.restore();
picker.restore();
vi.restoreAllMocks();
});
it(
"creates session, annotates, exports ZIP, re-imports ZIP, and round-trips the citation card",
{ timeout: 30000 },
async () => {
const user = userEvent.setup();
await loadApp();
// Step 1: empty state.
await screen.findByTestId("empty-state");
// Step 2: create session "Demo".
const nameInput = screen.getByTestId("empty-state-input");
await user.type(nameInput, "Demo");
await user.click(screen.getByTestId("empty-state-create"));
// Wait for the active session UI (collection list + upload dropzone).
await screen.findByTestId("upload-dropzone");
// Step 3: upload a synthetic PDF.
installClipboardSpy();
const fileBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
const pdfFile = new File([fileBytes], "e2e.pdf", { type: "application/pdf" });
const uploadInput = screen.getByTestId("upload-file-input") as HTMLInputElement;
await user.upload(uploadInput, pdfFile);
// Wait for the viewer to mount with the uploaded doc.
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
// Step 4: inject a selection + save evidence with commentary.
await act(async () => {
viewerSnapshot.onSelectionCaptured!(
syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
[{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
);
});
await user.type(
screen.getByPlaceholderText(/Add a one-line comment/),
"E2E session commentary",
);
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await screen.findByText(/E2E session commentary/);
// Step 5 (sanity): export the evidence as Markdown via the sidebar.
installClipboardSpy();
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalled());
const firstCard = writeText.mock.calls[0]![0] as string;
expect(firstCard).toContain(FIXTURE.known_good_quote);
expect(firstCard).toContain("E2E session commentary");
expect(firstCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/);
// Step 6: click Export ZIP from the session menu and capture the
// generated Blob via the URL.createObjectURL spy.
const urlCountBeforeExport = url.blobs.length;
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(await screen.findByTestId("session-menu-export"));
await waitFor(() => expect(url.blobs.length).toBeGreaterThan(urlCountBeforeExport));
// The exported ZIP is the most-recently-minted blob URL'd to <a download>.
const zipBlob = url.blobs[url.blobs.length - 1]!;
expect(zipBlob.size).toBeGreaterThan(0);
// Step 7: import the ZIP back via the menu. Intercept the file
// input the App opens, set our captured blob as its file, fire
// change.
const inputsBefore = picker.inputs.length;
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(await screen.findByTestId("session-menu-import"));
// The handler created a new file input.
await waitFor(() => expect(picker.inputs.length).toBeGreaterThan(inputsBefore));
const importInput = picker.inputs[picker.inputs.length - 1]!;
const zipFile = new File([await zipBlob.arrayBuffer()], "demo.zip", {
type: "application/zip",
});
// happy-dom's `files` is a getter on HTMLInputElement.prototype;
// a value-override on the instance silently doesn't take effect.
// Replace with a property getter that returns our fake FileList.
const fakeFileList = {
0: zipFile,
length: 1,
item: (i: number) => (i === 0 ? zipFile : null),
[Symbol.iterator]: function* () {
yield zipFile;
},
};
Object.defineProperty(importInput, "files", {
configurable: true,
get: () => fakeFileList,
});
await act(async () => {
if (typeof importInput.onchange === "function") {
importInput.onchange(new Event("change"));
}
// Yield so the import promise gets a chance to start.
await new Promise((r) => setTimeout(r, 0));
});
// Wait for the merge to manifest in the DOM. ADR-0008's additive
// policy means we should now see TWO evidence rows for the same
// commentary (the original + the imported copy), and the
// collection should still show ONE document (deduped by
// fingerprint).
await waitFor(
() => {
const matches = screen.queryAllByText(/E2E session commentary/);
expect(matches.length).toBeGreaterThanOrEqual(2);
},
{ timeout: 12000 },
);
await waitFor(() => {
const items = screen
.getByTestId("collection-list-items")
.querySelectorAll("li");
expect(items.length).toBe(1);
});
// Step 8: export the *merged* evidence as Markdown and assert the
// round-trip preserves quote + commentary + URL shape.
installClipboardSpy();
const toggles = screen.getAllByLabelText("Export evidence item");
// Use the last evidence row (the one from the import) just to
// distinguish from the original.
await user.click(toggles[toggles.length - 1]!);
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalled());
const mergedCard = writeText.mock.calls[0]![0] as string;
expect(mergedCard).toContain(FIXTURE.known_good_quote);
expect(mergedCard).toContain("E2E session commentary");
expect(mergedCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/);
},
);
});

View File

@@ -166,6 +166,7 @@ event vocabulary for the MVP is:
```
DocumentImported
DocumentRepresentationGenerated
DocumentRemoved
AnnotationCreated
AnnotationResolved
AnnotationResolutionFailed
@@ -179,8 +180,17 @@ CitationCardRendered
CitationRecoveryStarted
CitationRecoveryCandidateFound
CitationRecoveryConfirmed
SessionCreated
SessionRenamed
SessionDeleted
SessionActivated
```
The `Session*` events live on the cross-session session bus (the
SessionService's own EventBus instance — see CE-WP-0005). The remaining
events live on the per-session engine bus and are scoped to whatever
session is currently active.
Subsystems must emit these events through a shared event bus owned by
`citation-engine`. Subsystems may listen to any event but must not invent
event types without updating this document.

View File

@@ -8,10 +8,10 @@ repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
state_hub_workstream_id: 539919a4-f876-42b7-a28c-1a754c333139
status: todo
status: done
owner: Bernd
created: 2026-05-24
updated: 2026-05-24
updated: 2026-05-25
depends_on_workplan: CE-WP-0002
spec_refs:
- wiki/ProductRequirementsDocument.md
@@ -52,7 +52,7 @@ T01 (CitationCard type + open-context URL convention)
id: CE-WP-0004-T01
state_hub_task_id: c15369da-0c42-4b5b-9d8c-cb91b316850f
priority: high
status: todo
status: done
```
Under `src/shared/`:
@@ -74,7 +74,7 @@ scheme stays the same.
id: CE-WP-0004-T02
state_hub_task_id: 4f94d27e-3727-4ad3-8f42-b0bb7ca74cb7
priority: high
status: todo
status: done
depends_on: [T01]
```
@@ -105,7 +105,7 @@ Unit tests: snapshot a few rendered cards against fixtures.
id: CE-WP-0004-T03
state_hub_task_id: 40c1aa86-64a1-4216-9e33-29fddf9c4d62
priority: high
status: todo
status: done
depends_on: [T01]
```
@@ -129,7 +129,7 @@ here — it ships in a later workplan.
id: CE-WP-0004-T04
state_hub_task_id: 0fa3c7e4-6868-4010-ade4-99a921ab0578
priority: medium
status: todo
status: done
depends_on: [T02, T03]
```
@@ -152,7 +152,7 @@ Markdown (the most common action).
id: CE-WP-0004-T05
state_hub_task_id: 8d1993b3-21a8-4be9-9ab1-29aa97e8301c
priority: medium
status: todo
status: done
depends_on: [T04]
```

View File

@@ -0,0 +1,507 @@
---
id: CE-WP-0005
type: workplan
title: "Demo app — Named sessions, document uploads, ZIP export/import"
domain: citation_evidence
repo: citation-evidence
repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
state_hub_workstream_id: ec88caf3-85ad-413c-8ddd-ef7278f6ce57
status: done
owner: Bernd
created: 2026-05-25
updated: 2026-05-26
depends_on_workplan: CE-WP-0004
spec_refs:
- wiki/ProductRequirementsDocument.md
- wiki/ArchitectureOverview.md
- wiki/SharedContracts.md
---
# CE-WP-0005 — Demo App: Sessions + Uploads + ZIP Archive
Turn the MVP into a self-contained demo that a stranger can pick up and use.
After this workplan, a user can:
1. Land on the app and create a **named session** ("Lease 2024", "Klage
Müller", …).
2. Drag-drop or pick **arbitrary PDFs** into that session (no fixtures
required).
3. Annotate, build evidence, link to form fields, and export citation
cards — same flows as CE-WP-0002..0004, now scoped to the active
session.
4. **Export the whole session** as a single `.zip` archive: every PDF
plus a manifest with the engine snapshot.
5. **Import a `.zip` back** — into a new session, or merged into an
existing one (documents deduped by fingerprint; annotations and
evidence added additively).
The demo replaces the current single-bucket app: Review and Forms modes
both become **session-scoped**. The previous fixture-driven workflow
survives as an optional "Sample sessions" quick-start.
## Scoping decisions (locked before drafting)
- **Demo placement:** the demo *replaces* the main app. The MVP Review
and Forms layouts continue to work, but now under a session.
- **PDF byte storage:** in-memory only. PDFs survive within a tab
session; reloading the page loses uploaded bytes unless the ZIP was
exported. Re-importing the ZIP restores them. No IndexedDB tier in
this workplan.
- **Import conflict policy:** if a ZIP carries a session name that
already exists, **merge**. Documents are deduped by SHA-256
fingerprint (incoming references rebound to the existing
`documentId`). Annotations, evidence, and links are added as fresh
ids — additive, never overwriting. Locked in
[`ADR-0008`](../docs/decisions/ADR-0008-session-archive-format.md)
(created in T05).
## Dependency Order
```
T01 (Session model + service + per-session snapshots)
├─ T02 (PdfByteStore + uploaded-document ingest path)
│ └─ T03 (Upload UI + session-scoped CollectionList)
└─ T04 (Session management UI — top-bar menu + hash routing)
T05 (ADR-0008 + SessionArchive schema)
├─ T06 (Export session as ZIP) ───┐
└─ T07 (Import ZIP with merge) │
T08 (E2E test of full flow)
```
T03 and T04 can land in either order once T01+T02 are done. T06 and T07
can be parallelised within T05.
---
## T01 — Session model + service + per-session engine snapshots
```task
id: CE-WP-0005-T01
state_hub_task_id: 5b479bf5-b54a-4fc8-b500-ec49f5d68f6a
priority: high
status: done
```
Under `src/shared/`:
- `src/shared/session.ts``SessionId` branded type added to
`ids.ts`; `Session` interface with `{ id, name, createdAt,
updatedAt, lastOpenedAt? }`. No `documentIds` field — membership is
implicit (a session "owns" the documents in its engine snapshot).
Under `src/engine/`:
- Extend `events/types.ts` with the four new events:
`SessionCreated`, `SessionRenamed`, `SessionDeleted`,
`SessionActivated`. Add to the `EngineEvent` union.
- `src/engine/services/sessions.ts``SessionService` with
`create(name)`, `rename(id, name)`, `delete(id)`, `list()`,
`get(id)`, `setActive(id | null)`, `getActive()`. Backed by a
repo + the event bus.
- `src/engine/repos/in-memory-sessions.ts` — Map-backed
`SessionRepository`.
Per-session **engine snapshot persistence**:
- `STORAGE_KEY` becomes a function of `sessionId`:
`citation-evidence:session:<sessionId>:engine-snapshot:v1`.
- A separate index key
`citation-evidence:sessions:v1` stores the list of all known
sessions.
- The active session id is held in
`citation-evidence:active-session-id:v1`.
- When the active session changes, the old engine snapshot's
persister stops; a new persister is attached against the new
session's key. The engine itself is **recreated** (the cleanest
way to reset every in-memory repo); `EngineProvider` is keyed by
`sessionId` so React unmounts/remounts on switch.
Tests:
- Unit: `SessionService` lifecycle (create/rename/delete/setActive),
event emission, conflict on rename to a duplicate name.
- `restoreFromStorage` round-trip with the new per-session key
scheme — drop in a fixture set of two sessions, restore each,
assert engines hold the right documents.
---
## T02 — PdfByteStore + uploaded-document ingest path
```task
id: CE-WP-0005-T02
state_hub_task_id: 25626309-4cad-44b5-ac44-7e0dc7ea48fa
priority: high
status: done
depends_on: [T01]
```
Under `src/source/pdf/`:
- `byte-store.ts``createPdfByteStore()` returns a
`Map<DocumentId, Uint8Array>` wrapper with `put`, `get`, `delete`,
`list`, `clear`. Scoped per session (one instance per active
session; replaced on switch).
- `upload.ts` — `ingestPdfFromFile(file: File | Blob, store):
Promise<{ document, representation }>`:
1. Read bytes via `file.arrayBuffer()`.
2. Call existing `ingestPdf(bytes, { filename: file.name })`.
3. `store.put(document.id, bytes)`.
4. Mint a `blob:` URL from a fresh `Blob([bytes], { type: "application/pdf" })`
and stash it on `document.uri` so the viewer adapter can mount it.
5. Return the engine inputs ready for
`engine.documents.register(...)`.
- Blob URL **revocation**: when a document is deleted from a session,
`URL.revokeObjectURL(document.uri)` runs before the engine drops the
record. A small helper inside the byte store handles this so the
app layer doesn't have to remember.
The fixture-loading path (current `App.tsx` fetch + `ingestPdf`)
remains as-is for the optional "Sample sessions" quick-start; the
upload path is a parallel branch that ends at the same engine call.
Tests:
- Unit: round-trip a known-bytes PDF through `ingestPdfFromFile`,
assert `store.get(documentId)` returns the same bytes.
- Unit: delete revokes the blob URL exactly once even if called
twice.
---
## T03 — Upload UI + session-scoped Collection list
```task
id: CE-WP-0005-T03
state_hub_task_id: 55275918-e610-4513-ba2d-c05018ecd42d
priority: high
status: done
depends_on: [T02]
```
Under `src/app/sessions/`:
- `UploadDropzone.tsx` — drag-drop region and a file picker that
accepts `application/pdf` (multi-select). On drop:
1. For each `File`: call `ingestPdfFromFile(file, byteStore)` then
`engine.documents.register(...)`. Show a per-file progress chip;
surface a toast on failure.
2. Make the most-recently-uploaded document the active document.
Under `src/work/CollectionList.tsx`:
- Rework to list the **active session's** documents (read from
`engine.documents.list()`), not the fixture manifest.
- Header bar with the session name + an inline "Upload PDF" button
that opens the dropzone.
- Per-item: title (filename), document id, "Open" + "Delete" actions.
Delete confirms via a small inline state, then calls into the byte
store + engine repo.
Fixtures become an optional **Sample sessions** entry inside the
session menu (T04). The current `CollectionList`'s manifest-driven
fixture loader moves into
`src/app/sessions/SampleSessions.tsx`, kept for tests and
demonstration.
Tests:
- DOM: dropping a synthetic File triggers ingest and the new document
appears in the list.
- DOM: per-item delete removes the row and revokes the blob URL.
---
## T04 — Session management UI (top-bar menu, hash routing)
```task
id: CE-WP-0005-T04
state_hub_task_id: e008524c-9cef-448f-b95b-fa524c725bc3
priority: medium
status: done
depends_on: [T01]
```
Under `src/app/sessions/`:
- `SessionMenu.tsx` — top-bar dropdown showing the active session
name. Menu items:
- **Switch to…** (list of all sessions sorted by `lastOpenedAt`)
- **New session…** (opens an inline name-input modal)
- **Rename…**, **Delete…** (with confirmation) for the active
session
- **Export ZIP** (T06)
- **Import ZIP** (T07)
- **Sample sessions ▸** (T03, optional submenu)
- Empty state: if no sessions exist, the app body is replaced by a
centred "Create your first session" call-to-action with an inline
name input.
Hash routing:
- Current routes (`#/forms/demo`, default Review) become
session-scoped: `#/s/<sessionId>`,
`#/s/<sessionId>/forms/demo`. The active-session pointer is the
router's responsibility (single source of truth = the hash); the
`SessionService.setActive(...)` call is a side effect of hash
change.
- A bare `#/` (no session) renders the empty state.
- Deep links into a deleted/unknown session redirect to the empty
state with a toast.
Tests:
- DOM: switching sessions in the menu updates the hash and unmounts +
remounts the engine (verified by checking that the previous
session's documents disappear from the CollectionList).
- DOM: deep-link to a known session loads that session's documents.
---
## T05 — ADR-0008 + SessionArchive manifest schema
```task
id: CE-WP-0005-T05
state_hub_task_id: 50d525b1-ba7d-454e-91b4-34d96bc5ab7b
priority: high
status: done
```
Add `docs/decisions/ADR-0008-session-archive-format.md`. Locks:
- **ZIP layout**:
```
manifest.json
documents/
<documentId>.pdf
```
`<documentId>` is the engine's branded id (`doc_…`), used as the
filename. Future variants (per-representation files,
per-attachment) are intentionally deferred.
- **manifest.json shape** (top-level fields):
- `schemaVersion: 1`
- `exportedAt: string` (ISO-8601)
- `session: { id, name, createdAt, updatedAt }`
- `engine: EngineSnapshot` (the same shape produced by
`captureSnapshot()` — re-used verbatim so the round-trip stays
one-way).
- `documentBindings: Array<{ documentId, filename, fingerprint }>` —
pairs every engine document with its file inside `documents/`.
- **Merge-on-name-collision policy** (T07 spec): documents are
deduped by fingerprint; annotations/evidence/links are imported
with fresh ids and rebound to the deduped `documentId`. Re-importing
*your own* freshly-exported ZIP into the same session therefore
duplicates annotations — documented as a known limitation. A
later workplan can add idempotent imports via an `importBundleId`
field.
Under `src/shared/`:
- `session-archive.ts` — TypeScript interfaces for
`SessionArchiveManifest` matching the ADR. Pure types + a
`parseSessionArchiveManifest(json: unknown):
SessionArchiveManifest` that throws on schema mismatch (used by
the importer in T07).
Tests:
- Unit conformance: round-trip a synthetic manifest object →
`JSON.stringify` → parse → deep-equal.
- Unit failure: a manifest missing required fields, or with the
wrong `schemaVersion`, throws with a useful message.
---
## T06 — Export session as ZIP archive
```task
id: CE-WP-0005-T06
state_hub_task_id: 07546a24-90d8-4b5d-9833-2648d2936ea2
priority: high
status: done
depends_on: [T05]
```
Dependency add: `jszip` (small, MIT, battle-tested). Use the ESM
build to keep the bundle clean.
Under `src/app/sessions/`:
- `exportSessionZip.ts`:
```ts
export async function exportSessionZip(
sessionId: SessionId,
engine: Engine,
byteStore: PdfByteStore,
session: Session,
): Promise<Blob>
```
Steps:
1. Build the manifest from `captureSnapshot(engine)` + session
metadata + per-document `{ filename, fingerprint }` derived
from `engine.documents`.
2. For each `documentBindings[i]`, push `bytes` into
`documents/<documentId>.pdf`.
3. Push `manifest.json` (stringified, pretty-printed).
4. `zip.generateAsync({ type: "blob" })`.
- `triggerSessionDownload(blob, filename)` — creates an `<a download>`
element, clicks it, revokes the URL. Filename:
`<slugified session name>-<ISO date>.zip`.
UI:
- **Export ZIP** menu item in `SessionMenu` calls the above. Show a
brief spinner state on the menu item while the zip generates.
- Surface a success/error toast (re-use the toast pattern from
CE-WP-0004 sidebar, lifted into `src/app/sessions/Toast.tsx`).
Tests:
- DOM: synthesise a one-document session, click Export, capture the
generated Blob, unzip in the test (via JSZip), assert the manifest
matches the engine snapshot and `documents/<id>.pdf` contains the
original bytes.
---
## T07 — Import ZIP with merge-on-name-collision + fingerprint dedup
```task
id: CE-WP-0005-T07
state_hub_task_id: 2fedab8d-6af7-458a-90d3-383241978f4e
priority: high
status: done
depends_on: [T05]
```
Under `src/app/sessions/`:
- `importSessionZip.ts`:
```ts
export interface ImportSessionResult {
readonly sessionId: SessionId;
readonly outcome: "created" | "merged-into";
readonly stats: {
readonly documentsAdded: number;
readonly documentsDeduped: number;
readonly annotationsAdded: number;
readonly evidenceAdded: number;
readonly linksAdded: number;
};
}
export async function importSessionZip(
file: File | Blob,
services: SessionImportServices,
): Promise<ImportSessionResult>;
```
Steps:
1. Read with JSZip; parse `manifest.json` via
`parseSessionArchiveManifest`. Reject on schema mismatch.
2. Find target session: if a session with the manifest's
`session.name` exists → that one (`merged-into`). Else: create a
fresh session (`created`) preserving the imported name.
3. For each `documentBindings[i]`:
- If a document with the same `fingerprint` already lives in the
target session's engine: reuse its `documentId`; record a
remap `incoming.documentId → existing.documentId`. Skip the
bytes (we already have them).
- Else: register a new engine document with a freshly minted
`documentId` (the manifest's id is not preserved — it could
collide with future imports). Push bytes into the byte store.
Remap `incoming.documentId → new.documentId`.
4. Apply remaps:
- Annotations: mint new `annotationId`, rebind `documentId` to
the remapped value, call `engine.annotations.create(...)`.
- Evidence: mint new `evidenceItemId`, remap
`annotationIds`, call `engine.evidence.create(...)`.
- EvidenceLinks: mint new `evidenceLinkId`, remap
`evidenceItemId`, call `bindings.linkEvidenceToTarget(...)`.
5. Switch the active session to the target.
- Errors surface as toasts (corrupt zip, version mismatch, missing
binary file referenced by the manifest, generic IO).
UI:
- **Import ZIP** menu item in `SessionMenu` opens a file picker
(`accept=".zip,application/zip"`).
- After success: show a toast like *"Imported 'Demo' — 1 new
document, 2 annotations, 1 evidence item"*.
Tests:
- Unit: in-process round-trip — export a synthetic session, import
into an empty engine, assert outcome=`created`, document/anno/ev
counts match.
- Unit: same export, but import into a session of the same name that
already holds the document → assert `outcome="merged-into"`,
`documentsDeduped=1`, annotation/evidence counts double (additive
behaviour, per ADR-0008).
- Unit: corrupt manifest (drop a required field) → import rejects
with the parser error.
---
## T08 — E2E test of full create → annotate → export → reimport flow
```task
id: CE-WP-0005-T08
state_hub_task_id: 72d92828-8814-4034-96cc-7da5b6a5e281
priority: high
status: done
depends_on: [T03, T04, T06, T07]
```
`tests/integration/session-export-reimport.dom.test.tsx`. Mocks: the
PDF viewer (same pattern as
`tests/integration/citation-card-export-e2e.dom.test.tsx`); the
ingest path can use the real `ingestPdf` because we hand it real
fixture bytes.
Walk:
1. Load the app — empty state appears.
2. Create session "Demo" via the inline name input.
3. Upload a fixture PDF (read fixture bytes via
`node:fs` and wrap in a `File`).
4. Inject a selection for the manifest's known-good quote → save
evidence with a commentary.
5. (Sanity) Click Export → Copy as Markdown; assert the clipboard
payload contains the quote + commentary + openContextUrl.
6. Click **Export ZIP** in the SessionMenu. Intercept the
`<a download>` invocation; capture the Blob.
7. Click **Import ZIP** with the captured Blob. Assert merge:
- `outcome="merged-into"`, `documentsDeduped=1`,
`annotationsAdded=1`, `evidenceAdded=1`.
- The CollectionList still shows one document (deduped).
- The EvidenceSidebar now shows **two** evidence rows for the
same passage (one original + one from the merge — the known
additive behaviour per ADR-0008).
8. Click Export → Copy as Markdown on the *merged* evidence item;
assert the citation card output matches the original (proves the
round-trip preserves quote + commentary + URL shape).
If T08 passes, the MVP demo loop is complete and the project can ship
as a usable single-page demo.
---
## Out of scope (deferred to later workplans)
- **IndexedDB persistence** of PDF bytes between page reloads.
Currently only the ZIP path persists binaries.
- **Idempotent re-imports** (avoiding annotation duplication when
re-importing your own export). Requires an `importBundleId` field
in the manifest and a dedupe pass during T07. Track as a future
improvement; ADR-0008 already calls it out.
- **Session sharing via URL** (one-click "open this session in a
read-only viewer"). Adjacent to the deep-link URL scheme from
`wiki/ArchitectureOverview.md` §14.3 but not in scope here.
- **Per-document download** (export a single PDF + its annotations
as a `.zip`). The session-level export covers the demo loop; a
per-document variant is a small follow-up if asked for.
- **Polish**: branding, theme, first-run tutorial. Once the loop
works end-to-end, a separate workplan can tackle the look-and-feel.