Compare commits

...

35 Commits

Author SHA1 Message Date
c292afff6d Draft capability entry (reuse-surface REUSE-WP-0017-T04, cohort 1)
Honest first-pass maturity vector grounded in README/docs/tests present
in this repo; no invented evidence. Flagged for human review before
publish. See reuse-surface history/2026-07-06-coverage-classification.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 19:03:42 +02:00
943eef490e Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:24 +02:00
80b4284836 chore(state-hub): write back CE-WP-0009 workstream and task IDs
Sync workplan frontmatter with state-hub after fix-consistency.
2026-06-22 20:28:41 +02:00
3591554874 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for citation-evidence
2026-06-22 20:27:28 +02:00
85a562b2a1 Add make check-install for umbrella layout consistency
Verify sibling citation-engine checkout, link dependency resolution,
toolchain, and absence of stale in-repo engine copies. Prints actionable
fix hints when the installation layout is wrong.
2026-06-22 20:10:38 +02:00
dd2f2115bd Implement CE-WP-0009: wire umbrella to @citation-evidence/engine
Add link: dependency on citation-engine, retarget @shared/@engine aliases,
remove in-repo shared/engine copies. ADR-0002 accepted (option B).
172 tests, typecheck, and lint pass.
2026-06-22 19:45:11 +02:00
bb911eef37 Add CE-WP-0009 engine workspace wireup workplan
Wire umbrella to @citation-evidence/engine via link: dependency and
remove in-repo shared/engine copies. Index post-MVP extraction track.
2026-06-22 19:32:59 +02:00
6d2a12fcdb Human-review .repo-classification.yaml (CUST-WP-0050 follow-up) 2026-06-22 17:56:16 +02:00
ac35cbf476 Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:35 +02:00
6b97c0d693 Move MVP workplan index out of workplans/ for ADR-001 compliance.
workplans/*.md must be YAML-frontmatter workplan files. The human index
lives at docs/mvp-workplans-index.md instead.
2026-06-22 01:16:15 +02:00
7e1b9e5967 Add capability registry scaffold (REUSE-WP-0014-T03 B01)
Empty helix_forge registry layout for federation publishing.
2026-06-16 01:50:28 +02:00
b28feaad42 Persist capture data per session (field values, schema, links)
Capture mode state lived only in React memory and was lost when
reopening a session or remounting EngineProvider.

- Add per-session localStorage capture snapshot (schema, values, links)
- Restore on session mount; persist on field/schema/link changes
- Seed binder links from storage without spurious bus events
- Clean up capture key when session is deleted
- Integration test for reload persistence
2026-06-08 01:23:48 +02:00
ba34ba868f Fix viewport jumping back to top after evidence scroll
PdfLoader reloads the PDF when its document prop is a new object each
render. Memoize the loader config on pdfUrl only.

Also stabilize SpikeHighlightContainer via context (no remount on focus
change) and narrow scroll-effect deps to highlight id signature.
2026-06-08 01:17:41 +02:00
50c29da4b1 CE-WP-0008: add state-hub workstream and task IDs from fix-consistency 2026-06-08 01:08:16 +02:00
305a20d1d1 CE-WP-0008: fix capture field values and viewport scroll retry
- Wire fieldValues state in FormsApp so controlled inputs persist typed data
- Add runScrollToHighlightJob with rAF retries when utils/highlights not ready
- Re-trigger scroll when highlights update after PDF load
- Tests: scroll-job unit test, forms-field-values integration tests
- Workplan CE-WP-0008 marked done
2026-06-08 01:07:52 +02:00
fc6b91ccb0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-08:
  - update .custodian-brief.md for citation-evidence
2026-06-08 00:46:50 +02:00
48a53df9fc CE-WP-0007 T10-T12: field add/edit dialog, pencil edit, integration tests
- FieldDefinitionForm shared component (label + text/textarea/date)
- Add field opens inline form; per-field pencil edit with stable ids
- forms-field-edit.dom.test.tsx covers add, edit, and link-to-new-field
- Workplan T10-T12 and README marked done
2026-06-08 00:46:06 +02:00
2fd085b65e CE-WP-0006/0007: Capture view polish, workplans, and UX refinements
- Blob URL stability, scroll centre, strip-only visual guide
- Focus-gated linking, unlink clears overlay, field badge tooltips
- Capture layout (viewer centre), grey guide lines, Add field button
- Workplans CE-WP-0006 (done) and CE-WP-0007 (T01-T09 done, T10-T12 todo)
- Integration tests and viewer-url helpers
2026-06-08 00:37:34 +02:00
d25b01f6d5 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-08:
  - update .custodian-brief.md for citation-evidence
2026-06-08 00:34:28 +02:00
3f5b0a1734 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-08:
  - update .custodian-brief.md for citation-evidence
2026-06-08 00:33:52 +02:00
9c77d104cf chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-08:
  - update .custodian-brief.md for citation-evidence
2026-06-08 00:33:17 +02:00
cfa4e9f790 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-08:
  - update .custodian-brief.md for citation-evidence
2026-06-08 00:03:08 +02:00
88c7d97f54 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-07:
  - update .custodian-brief.md for citation-evidence
2026-06-07 23:59:35 +02:00
7463466845 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-07:
  - update .custodian-brief.md for citation-evidence
2026-06-07 23:16:23 +02:00
8082aaf7ec Load pdfjs-dist's own pdf_viewer.css so text-layer spans position correctly
The version of pdf_viewer.css bundled with react-pdf-highlighter-plus
is only a minimal *override* (≈40 lines: opacity, z-index, blend
mode). It's missing the foundational rules that PDF.js's TextLayer
relies on — `position: absolute`, `inset: 0`, and the entire
`--scale-factor` CSS-variable machinery that PDF.js 4.x uses to
position each glyph.

Without those rules, each text-layer span gets rendered with default
positioning context and `font-size: calc(<base> * var(--scale-factor))`
collapses to 0 → spans either pile up at the top-left of the page or
land at wrong y-coordinates regardless of where the glyph actually
sits on the canvas. The reported symptom ("origin seems to be the top
of the page always") matches exactly.

Importing `pdfjs-dist/web/pdf_viewer.css` first, then the library's
overrides on top, gives PDF.js the CSS it expects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:34:03 +02:00
c000ce6f73 Wire pdfjs cmaps + standard fonts so text layer positions correctly
Strong likelihood that the "text layer is misplaced / body text not
selectable" symptoms across multiple PDFs come from PDF.js falling
back to substitute font metrics. Without the cmaps directory (CID
character maps for non-Latin fonts) and the standard_fonts directory
(Helvetica/Times/Courier metrics for unembedded standard fonts), the
canvas glyphs use embedded font data while the text-layer span
positions are computed from fallback metrics. The two diverge — text
spans land in the wrong place, or text content can't be decoded at
all, leaving the body unselectable.

Both directories are now copied into the served root by
vite-plugin-static-copy and passed to pdfjs.getDocument() as
`cMapUrl: "/cmaps/"` + `cMapPacked: true` + `standardFontDataUrl:
"/standard_fonts/"` via PdfLoader's `document` prop (which accepts a
full DocumentInitParameters object).

If this is the right diagnosis, the textLayer overlay should now line
up with the visible glyphs on the same PDFs that were producing
fragmented captures. If the body text is still unselectable, the PDF
genuinely lacks a text layer for those glyphs (image-only content)
and OCR would be the only path forward.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:38:34 +02:00
3834f5c209 Fix document card palette: always blue, thicker border when active
The previous iteration left inactive document cards on a
white-with-grey-border style and only flipped to light-blue on
activation. The intent (matching the evidence-card pattern of
always-yellow with a thicker border when active) was to always-blue
with a thin/thick dark-blue border.

Inactive: 1px #0050b3 on #e8f0ff
Active:   3px #0050b3 on #e8f0ff

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:17:36 +02:00
bef2725fdd Unify capture/edit form, thicker active document border, layer-hide toggles
Three UX iterations rolled into one:

1. Unified evidence form
   - New EvidenceFormBody is the single source for "citation +
     commentary" editing. Both InlineCaptureForm (creating fresh
     evidence from a selection) and the EvidenceCard edit mode render
     this body with their own save/cancel labels + badge/helper text.
   - The capture form now exposes the citation as an editable
     textarea — pre-filled with the selection text — so the user can
     refine a partial capture before saving without re-selecting.
   - Old testid prefixes are unchanged for the inline-capture flow
     (`inline-capture-quote/commentary/save/cancel`); edit-mode
     testids are now `evidence-edit-<id>-{quote,commentary,save,cancel}`.

2. Active document card
   - The blue background alone was the only "this is open" cue. Added
     a 3px #0050b3 border (matching the evidence-card thick-border
     pattern, but in the documents-are-blue palette) plus a
     `data-active` attribute.

3. PDF layer-hide diagnostics
   - New debug flags `hideCanvas`, `hideTextLayer`, `hideAnnotationLayer`,
     `hideXfaLayer` — applied as `.ce-hide-<layer>` classes on the viewer
     wrapper, each `display: none`-ing the matching PDF.js layer.
   - SessionMenu groups the toggles under a "PDF diagnostics" header
     with a new shared DebugCheckbox helper. The existing "Debug text
     layer" highlight toggle now lives in the same group.
   - Lets the user isolate stacking issues by elimination — e.g.
     "hide text layer, can I now see the canvas content underneath?".

Tests
   - citation-card-export-e2e + session-export-reimport switched from
     placeholder/role-name lookups to the inline-capture testids so
     they survive form-copy changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:27:08 +02:00
f42b4ec87c Auto-generate session name when user creates without typing one
Click "Create session" with the input empty and a name of the form
`YYMMDD-session-NNN` is generated automatically: today's date as
two-digit year/month/day, then a zero-padded counter that starts at
000 and increments past the highest existing match for the same day.

Added:
- `computeNextDefaultName(existing, now?)` pure helper exported from
  `@engine/services/sessions`.
- `SessionService.nextDefaultName(now?)` method that wraps it
  against the current repo.
- Both create call sites (CreateFirstSession empty state +
  SessionMenu's New session form) fall back to `service.nextDefaultName()`
  when the trimmed input is empty.
- 5 new unit tests covering today-only counting, max-not-count
  increment, and trimmed/wrong-shape filtering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:07:17 +02:00
430c0e124c Refine evidence UX: sidebar capture form, inline edit, click highlight
Significant UX iteration:

Visual palette
- Debug text-layer overlay flips from yellow to light grey so it no
  longer collides with the evidence highlight colour.
- New highlight-styles.css matches the sidebar's #fff8d6/#e0c050
  palette so a passage marked in the document and its sidebar card
  speak the same visual language.
- Active (focused) evidence: same fill, thick #b78b1c outline on both
  the highlight and the sidebar card. Library's red --scrolledTo
  box-shadow is suppressed.

Activation model
- Click an evidence card in the sidebar → activates that item +
  scrolls the viewer to the passage + thickens the borders (existing
  behaviour, now visually clearer).
- Click a highlight in the document → activates the evidence that
  owns that annotation. New `findByAnnotationId()` on EvidenceService
  is the reverse lookup. Wired through a new `onHighlightClicked`
  prop on PdfSpikeViewer + `activeAnnotationId` prop that drives the
  data-ce-active attribute on the highlight wrapper.

Inline edit
- Each evidence card has a ✎ button that flips the card into an
  inline form with the citation (quote) and commentary fields.
- Saving calls a new `AnnotationService.updateQuote()` +
  existing `EvidenceService.updateCommentary()`. The selectors are
  untouched, so the marked passage in the document stays put — the
  inline hint says so explicitly.
- New `AnnotationUpdated` event added to the engine event vocabulary
  (SharedContracts.md §4 updated).

Capture form placement
- The yellow "New annotation" toolbar that lived above the viewer is
  gone. A new InlineCaptureForm component is now slotted into the
  sidebar between the cards that bracket the new selection in
  document flow (sorted by page + y of the first PdfRectSelector).
  If the new selection is before all existing evidence it appears at
  the top; if after all of them, at the bottom.
- The legacy AnnotationToolbar.tsx is removed; the public surface
  re-exports `InlineCaptureForm` instead.

Test updates
- tests/integration/citation-card-export-e2e.dom.test.tsx: switched
  to the seed-session helper (matches the other E2Es) since the
  fixture-button click path is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:57:48 +02:00
f0af8887d1 Strengthen text-layer debug + log highlight render path
The first cut of "Debug text layer" only painted direct `<span>`
children of `.textLayer`. PDF.js 4.x wraps marked content in nested
spans/divs, so the entire selectable area wasn't visible — making it
hard to tell whether a region is "no text layer at all" vs. "text
layer present but small/dense".

Changes:
- CSS now targets every descendant of `.textLayer`, dims the canvas
  underneath, and outlines the `.textLayer` container itself so its
  full extent is obvious.
- TextHighlight rectangles flip to green in debug mode so saved
  highlights don't get washed out by the debug yellow.
- The viewer now logs:
    [ce] viewer highlights        — which annotations rendered, which
                                    were skipped, with rects + page
    [ce] scrollToAnnotation       — whether the target was found in
                                    the highlights array when an
                                    activation arrived

This is the diagnostic loop for the "viewport scrolls but the
highlight doesn't appear" report — if highlight count is > 0 in the
first log but the green rectangle is off-screen, the saved rects
inherited the same text-layer misalignment that caused the partial
selection captures in the first place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:05:13 +02:00
0638c441c4 Add Debug text layer toggle for diagnosing PDF selection issues
PDF text selection misbehaviour (some glyphs unselectable, selections
jumping to other positions) is almost always caused by misalignment
between the visible canvas-rendered glyphs and the invisible text
layer that PDF.js overlays for selection. There's no way to see this
without devtools — which makes it hard for end users to tell whether
a specific PDF is OCR-noisy, has bad font fallbacks, or has no text
layer at all.

This adds a developer-facing toggle in the SessionMenu ("Debug text
layer") that:

- paints every text-layer span yellow with a blue outline so it's
  obvious where text is selectable and where it isn't, and
- logs every onSelection event to the browser console with the
  captured text, page, normalized rects, and the selectors the
  pipeline derived from it.

Preference persists in localStorage under
`citation-evidence:debug:textLayer`. Surfaced via a new
`useDebugFlag()` hook in @work so the SessionMenu (app layer) and the
ViewerShell (work layer) can both subscribe without breaching the
boundary plugin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:43:15 +02:00
67bcc2423c Add per-row session delete + Reset all data; fix viewer URL fallback
UX gaps that surfaced while running the demo:

- ViewerShell hardcoded `/fixtures/pdfs/<title>` for the PDF URL,
  ignoring the `document.uri` blob URL that uploaded PDFs carry. The
  viewer either 404'd or — worse — silently served a fixture whose
  filename happened to collide. Prefer document.uri when present.

- SessionMenu only let you delete the *active* session. Added a small
  per-row "✕" button next to every session in the Switch-to list so a
  user can drop a session's data without first switching to it. Same
  click-to-confirm pattern as the existing Delete action.

- Added a "Reset all data…" affordance in both the SessionMenu and the
  empty-state landing. Calls a new `clearAllSessionData()` helper that
  wipes every `citation-evidence:*` key from localStorage, then forces
  a reload so all in-memory caches start fresh.

- `attachSessionPersister.writeOnDelete` was leaking the per-session
  `active-document-id:v1` key on every session delete. Now removed
  alongside the engine snapshot key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:49:37 +02:00
d5474a1bd9 Set pdfjs GlobalWorkerOptions.workerSrc at app bootstrap
The PDF.js library refuses to open documents without a worker URL.
Production builds were throwing "No GlobalWorkerOptions.workerSrc
specified" on any upload because neither the source-layer ingest
(extract.ts) nor the viewer adapter ever set one — they relied on the
host application to do it, and the browser bootstrap didn't.

main.tsx now imports the worker via Vite's `?url` suffix so the file
is bundled into the build, and sets GlobalWorkerOptions.workerSrc
once before any PDF code runs. Added src/vite-env.d.ts so TypeScript
knows about the `?url` import suffix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:28:51 +02:00
a5f5c7d8a8 Add Makefile with preview target
`make preview` runs `pnpm build && pnpm preview` so the demo can be
served from the production bundle at http://localhost:4173/. Plus
convenience targets for dev, build, test, typecheck, lint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:17:33 +02:00
127 changed files with 5749 additions and 4473 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-evidence` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/CE-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **citation-evidence** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Umbrella product: integration shell, reference workspace, shared contracts (wiki/SharedContracts.md, wiki/DependencyMap.md), ADRs, and MVP source code during umbrella-first phase.
**Domain:** infotech
**Repo slug:** citation-evidence
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="citation-evidence", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-evidence&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:citation-evidence]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=citation-evidence
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=citation-evidence
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/CE-WP-NNNN-<slug>.md`
ID prefix: `CE-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-CE-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:citation-evidence]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: CE-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

18
.custodian-brief.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — citation-evidence
**Domain:** infotech
**Last synced:** 2026-06-22 18:27 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
*(none — repo may need first-session setup)*
---
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("infotech")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

26
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,26 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: human
category: product
domain: infotech
secondary_domains:
- communication
- government
capability_tags:
- citations
- evidence
- knowledge
- traceability
- source-management
business_stake:
- intelligence
- legal
- product
- technology
business_mechanics:
- control
- coordination
- adaptation
notes: Citation/evidence product; standard §13.5 — human confirmed.

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# citation-evidence — Agent Instructions
## Repo Identity
**Purpose:** Umbrella product: integration shell, reference workspace, shared contracts (wiki/SharedContracts.md, wiki/DependencyMap.md), ADRs, and MVP source code during umbrella-first phase.
**Domain:** infotech
**Repo slug:** citation-evidence
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `CE-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-evidence&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=citation-evidence&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=citation-evidence
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-evidence` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/CITATION-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-CITATION-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: CITATION-WP-NNNN
type: workplan
title: "..."
domain: infotech
repo: citation-evidence
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: CITATION-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=citation-evidence`
(or send a message to the hub agent via `POST /messages/`)

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# citation-evidence — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: preview dev build test typecheck lint check-install
# Build the production bundle then serve it locally with vite preview.
# Prints the URL (default http://localhost:4173/) once the server is ready.
preview:
pnpm build
pnpm preview
# Dev server with HMR (http://localhost:5173/).
dev:
pnpm dev
build:
pnpm build
test:
pnpm test
typecheck:
pnpm typecheck
lint:
pnpm lint
# Verify sibling citation-engine checkout, link dependency, and toolchain.
check-install:
@bash scripts/check-install.sh

View File

@@ -4,9 +4,9 @@ A document-centered evidence workspace for capturing, managing, presenting,
and re-opening citations. The umbrella over the six-package design described and re-opening citations. The umbrella over the six-package design described
in `INTENT.md` and `wiki/ArchitectureOverview.md`. in `INTENT.md` and `wiki/ArchitectureOverview.md`.
During the MVP all code lives here under `src/` (see "Repository layout" Shared types and engine services live in the extracted
below). Sister repos hold INTENT only — code migrates outward when each [`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`).
subsystem stabilises. Remaining partitions stay under `src/` until each subsystem extracts.
## Documentation ## Documentation
@@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md.
## Repository layout ## Repository layout
Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep).
``` ```
src/ src/
shared/ # vocabulary, types, pure helpers → becomes part of citation-engine
engine/ # services, repositories, event bus → becomes part of citation-engine
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
@@ -41,9 +41,9 @@ repo is intended to be a `git mv` plus a `package.json` cut — nothing more.
## Sister repos ## Sister repos
Peers under `~/`; each holds INTENT.md only during MVP: Peers under `~/`:
- [`~/citation-engine`](../citation-engine/) — shared model + engine services - [`~/citation-engine`](../citation-engine/) — **extracted** shared model + engine (`@citation-evidence/engine`)
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract - [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery - [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry - [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
@@ -54,6 +54,8 @@ Peers under `~/`; each holds INTENT.md only during MVP:
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9. Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
```bash ```bash
# citation-engine must be checked out next to this repo (../citation-engine)
make check-install # diagnose layout problems before install
pnpm install pnpm install
pnpm dev # vite dev server (once src/app/ has a real entry) pnpm dev # vite dev server (once src/app/ has a real entry)
pnpm test # vitest one-shot pnpm test # vitest one-shot
@@ -66,7 +68,7 @@ pnpm build # production bundle
## Workplans (Ralph) ## Workplans (Ralph)
Workplans drive incremental implementation through the ralph loop. The harness Workplans drive incremental implementation through the ralph loop. The harness
lives in `~/ralph-workplan/`; see `workplans/README.md` for the active list lives in `~/ralph-workplan/`; see `docs/mvp-workplans-index.md` for the active list
and ordering. and ordering.
```bash ```bash

137
SCOPE.md Normal file
View File

@@ -0,0 +1,137 @@
# SCOPE
> This file helps you quickly understand what this repository is about,
> when it is relevant, and when it is not.
> It is intentionally lightweight and may be incomplete.
---
## One-liner
<!-- Describe the purpose of this repository in one precise sentence. -->
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
---
## Core Idea
<!-- What is the main capability or idea behind this repository? -->
<!-- What problem does it try to solve? -->
---
## In Scope
<!-- What this repository is responsible for. -->
<!-- Be explicit and concrete. -->
-
-
-
---
## Out of Scope
<!-- What this repository deliberately does NOT do. -->
<!-- This is often more important than "In Scope". -->
-
-
-
---
## Relevant When
<!-- When should someone consider using or exploring this repository? -->
-
-
-
---
## Not Relevant When
<!-- When should someone ignore this repository? -->
-
-
-
---
## Current State
<!-- Rough indication of maturity. No strict format required. -->
- Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
- Implementation: <!-- e.g. idea / partial / substantial / complete -->
- Stability: <!-- e.g. unstable / evolving / stable -->
- Usage: <!-- e.g. none / personal / internal / production -->
<!-- Add any notes that help set expectations. -->
---
## How It Fits
<!-- Where does this repository sit in the bigger picture? -->
- Upstream dependencies:
- Downstream consumers:
- Often used with:
---
## Terminology
<!-- Terms that are important to understand this repo. -->
<!-- Especially useful if naming differs from other repos. -->
- Preferred terms:
- Also known as:
- Potentially confusing terms:
---
## Related / Overlapping Repositories
<!-- List repositories that have similar or adjacent responsibilities. -->
<!-- Helps detect duplication and navigate the ecosystem. -->
- <repo-name> — <!-- how it relates -->
---
## Getting Oriented
<!-- If someone decides to look deeper, where should they start? -->
- Start with:
- Key files / directories:
- Entry points:
---
## Provided Capabilities
<!-- What can this repo's domain provide to other domains on request? -->
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
<!-- Remove the examples and add your own, or leave empty if none. -->
<!--
```capability
type: infrastructure
title: Example capability title
description: What this capability provides, in one or two sentences.
keywords: [keyword1, keyword2, keyword3]
```
-->
---
## Notes
<!-- Anything else worth knowing. Keep it short. -->

View File

@@ -1,16 +1,19 @@
# ADR-0002 — Monorepo vs polyrepo for the six subsystems # ADR-0002 — Monorepo vs polyrepo for the six subsystems
- Status: proposed - Status: accepted
- Date: 2026-05-24 - Date: 2026-05-24
- Workplan: CE-WP-0001-T07 (stub) - Decided: 2026-06-22
- Workplan: CENG-WP-0002-T01
## Context ## Context
The umbrella-first MVP lives entirely in `citation-evidence/` under The umbrella-first MVP lives entirely in `citation-evidence/` under
`src/{shared,engine,anchor,source,binder,work,app}/`. Each folder is named `src/{anchor,source,binder,work,app}/` with shared types and engine services
after its eventual extracted package. At some point — driven by an external in the extracted `@citation-evidence/engine` package (`citation-engine` repo).
consumer needing one subsystem, or by independent release cadence — code
will move out into its sister repo. Each remaining folder is named after its eventual extracted package. At some
point — driven by an external consumer needing one subsystem, or by independent
release cadence — code will move out into its sister repo.
We need a written answer to: when that moment comes, do we (a) keep one We need a written answer to: when that moment comes, do we (a) keep one
repository with pnpm workspaces, (b) split into six independent repos with repository with pnpm workspaces, (b) split into six independent repos with
@@ -43,8 +46,29 @@ across the boundary.
## Decision ## Decision
(blank — to be answered before the first subsystem extraction lands.) **B — six independent repos with published packages**, using **`link:` sibling
dependencies during local development** until a registry is configured.
Rationale:
1. The ecosystem is already organized as six sister repos plus the umbrella;
independent repos match the documented architecture.
2. `citation-engine` extraction (`CENG-WP-0001`) and umbrella wireup
(`CE-WP-0009`) prove the `link:../citation-engine` dev workflow.
3. Publishing can be deferred — no registry is configured yet — without
blocking extraction of the remaining subsystems.
4. Option C adds tooling overhead before any external consumer exists.
## Consequences ## Consequences
(blank) - **Local dev:** sister repos sit as siblings under `~/` (or equivalent).
Consumers declare `"@citation-evidence/engine": "link:../citation-engine"`.
- **Publishing:** when a registry is chosen, bump `@citation-evidence/engine`
semver and replace `link:` with the registry version in consumer repos.
- **Contracts:** `citation-evidence/wiki/SharedContracts.md` stays authoritative;
`citation-engine/wiki/SharedContracts.md` is a conformance copy (see
`citation-engine/wiki/README.md`).
- **Versioning:** engine package semver tracks API/contract changes; umbrella
and sister repos pin or range-pin on publish.
- **CI:** each repo runs its own test/lint pipeline; cross-repo integration
tests remain in `citation-evidence` until subsystems extract fully.

View File

@@ -0,0 +1,59 @@
# MVP Workplans
MVP workplans for the citation-evidence umbrella repo. CE-WP-0001..0006
delivered the PRD §20 reference scenario and Forms/Review UX polish.
CE-WP-0007 delivered Capture-view polish including field add/edit UX.
CE-WP-0008 fixes capture field value persistence and viewport scroll reliability.
| Workplan | Title | Status |
|----------|----------------------------------------|--------|
| `CE-WP-0001` | Foundations — scaffold, folders, lint rules, normalize, fixtures | done |
| `CE-WP-0002` | PDF review slice — engine types, anchor, source, viewer, sidebar | done |
| `CE-WP-0003` | Form binding + visual guide — EvidenceLink, rect registry, overlay | done |
| `CE-WP-0004` | Citation card export — Markdown + HTML renderers, sidebar export | done |
| `CE-WP-0005` | Demo sessions — uploads, named sessions, ZIP export/import | done |
| `CE-WP-0006` | Forms & review UX refinements — blob fix, scroll centre, linking | done |
| `CE-WP-0007` | Capture view polish — scroll, linking, layout, rename, field UX | done |
| `CE-WP-0008` | Capture content editing & viewport scroll reliability | done |
## Post-MVP — extraction and distribution
| Workplan | Repo | Title | Status |
|----------|------|-------|--------|
| `CENG-WP-0001` | citation-engine | Extract engine from umbrella | done |
| `CE-WP-0009` | citation-evidence | Wire umbrella to `@citation-evidence/engine` | done |
| `CENG-WP-0002` | citation-engine | Package distribution (ADR-0002, publish prep) | done |
`CE-WP-0009` depends on `CENG-WP-0001`. `CENG-WP-0002` can run in parallel;
publish tasks wait on ADR-0002 resolution.
## Order
CE-WP-0001..0004 are strictly sequential. CE-WP-0005 depends on 0004.
CE-WP-0006 depends on 0005. CE-WP-0007 depends on 0006. CE-WP-0008 depends on 0007:
```
/ralph-workplan workplans/CE-WP-0008-capture-content-editing.md
```
## How to run a workplan
```
/ralph-workplan workplans/CE-WP-0001-foundations.md
```
Ralph drives the loop and retires automatically when all tasks in the
workplan are marked `done`. See `~/.claude/plugins/ralph-workplan/ralph-workplan.md`.
## Acceptance for MVP
The first reference scenario from PRD §20 runs end-to-end:
1. Create a collection
2. Upload a PDF
3. Select a passage, add commentary, create an evidence item
4. Open a side-by-side form
5. Link the evidence item to a form field
6. Focus the field → field, evidence card, and PDF passage all highlighted
7. SVG guide visible between field → card → highlight
8. Export evidence as a Markdown citation card

View File

@@ -1,23 +1,28 @@
// ESLint flat config (ESLint 9+). // ESLint flat config (ESLint 9+).
// Enforces the partition dependency map in wiki/DependencyMap.md §4. // Enforces the partition dependency map in wiki/DependencyMap.md §4.
// //
// shared/ and engine/ live in the linked @citation-evidence/engine package;
// boundary rules for those partitions are enforced in citation-engine.
//
// Element types (folders) and allowed importers: // Element types (folders) and allowed importers:
// shared : importable by every other element (no internal imports of its own). // shared : importable by every other element (package: citation-engine).
// engine : imports shared. // engine : imports shared (package: citation-engine).
// anchor : imports shared, engine. // anchor : imports shared, engine.
// source : imports shared, engine. // source : imports shared, engine.
// binder : imports shared, engine, anchor. // binder : imports shared, engine, anchor.
// work : imports shared, engine, anchor, source. (NOT binder.) // work : imports shared, engine, anchor, source. (NOT binder.)
// app : imports anything. // app : imports anything.
//
// Path aliases (@shared/*, @engine/*, etc.) come from tsconfig.json paths and
// are resolved by eslint-import-resolver-typescript.
import js from "@eslint/js"; import js from "@eslint/js";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import boundaries from "eslint-plugin-boundaries"; import boundaries from "eslint-plugin-boundaries";
import importPlugin from "eslint-plugin-import"; import importPlugin from "eslint-plugin-import";
import globals from "globals"; import globals from "globals";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const engineSrc = resolve(__dirname, "../citation-engine/src");
export default tseslint.config( export default tseslint.config(
{ {
@@ -41,8 +46,8 @@ export default tseslint.config(
typescript: { project: "./tsconfig.json" }, typescript: { project: "./tsconfig.json" },
}, },
"boundaries/elements": [ "boundaries/elements": [
{ type: "shared", pattern: "src/shared/**" }, { type: "shared", pattern: `${engineSrc}/shared/**` },
{ type: "engine", pattern: "src/engine/**" }, { type: "engine", pattern: `${engineSrc}/engine/**` },
{ type: "anchor", pattern: "src/anchor/**" }, { type: "anchor", pattern: "src/anchor/**" },
{ type: "source", pattern: "src/source/**" }, { type: "source", pattern: "src/source/**" },
{ type: "binder", pattern: "src/binder/**" }, { type: "binder", pattern: "src/binder/**" },

View File

@@ -0,0 +1,205 @@
# Ecosystem State Assessment — citation-evidence family
**Date:** 2026-06-07
**Author:** Grok (Cursor), commissioned by Bernd
**Scope:** Review of all six `INTENT.md` files in the citation-evidence family, plus the
umbrella repo's code, workplans, wiki contracts, and test coverage — to assess current
state and recommend next steps.
---
## 1. Family topology
The citation-evidence ecosystem comprises **one umbrella repo and five subsystem repos**:
```text
citation-evidence (umbrella — all MVP code lives here)
├── citation-engine (domain model, services, persistence, rendering)
├── evidence-anchor (selectors, resolution, viewer adapter contract)
├── evidence-source (ingest, extraction, citation recovery)
├── citation-work (review workspace UX)
└── evidence-binder (evidence-to-target binding, visual guide)
```
| Repo | Declared role | Actual state (2026-06-07) |
|------|---------------|---------------------------|
| **citation-evidence** | Umbrella product, contracts, reference app | **Active** — ~118 TS/TSX files, tests, workplans, wiki, ADRs |
| **citation-engine** | Domain model, services, persistence, rendering | **INTENT + README only** — code in `src/{shared,engine}/` |
| **evidence-anchor** | Selectors, resolution, viewer adapter | **INTENT + README only** — code in `src/anchor/` |
| **evidence-source** | Ingest, extraction, recovery | **INTENT + README only** — code in `src/source/` (PDF only) |
| **citation-work** | Review workspace UX | **INTENT + README only** — code in `src/work/` |
| **evidence-binder** | Evidence-to-target binding, visual guide | **INTENT + README only** — code in `src/binder/` |
This is **intentional**, not neglect. On 2026-05-24 the family adopted an
**umbrella-first MVP** (ADR-0002 context, `INTENT.md` §MVP Strategy): prove the product
in one repo, then extract subsystems once boundaries are validated by real use.
---
## 2. INTENT.md quality — design maturity is high
All six `INTENT.md` files are coherent and mutually reinforcing. They share:
- The same core flow:
`Document → DocumentRepresentation → Annotation → EvidenceItem → EvidenceLink → CitationCard`
- Explicit **in-scope / out-of-scope** boundaries (each repo pushes responsibilities outward)
- A consistent document shape (Purpose, Scope, Workflows, Success Criteria, Guiding Statement)
- A shared **"MVP Coordination — Code Lives Upstream"** section pointing at
`citation-evidence/wiki/`
The umbrella `INTENT.md` is the strategic anchor: it owns shared contracts, integration,
and the reference scenario. Sister repos document *future* homes, not current code.
### 2.1 Ambiguities from the original INTENTs — largely resolved
The initial assessment (`history/2026-05-24-initial-assessment.md`) flagged overlapping
ownership (selectors, evidence states, viewer adapters, recovery). Those have since been
codified in:
- `wiki/SharedContracts.md` — canonical enums, vocabulary, type/behavior split
- `wiki/DependencyMap.md` — allowed import edges, cycle prevention
- `docs/decisions/` — ADR-0004 (PDF viewer), ADR-0006 (selector ownership),
ADR-0005 (persistence), ADR-0007 (citation card format), ADR-0008 (session archive), etc.
Notable reconciliations baked into sister INTENTs:
- `strong-support` / `weak-support` / `contradicts` moved from `EvidenceItem.status`
to `EvidenceLink.relation`
- Selector **types** → engine; selector **algorithms** → anchor
- `citation-work` must not depend on `evidence-binder` (review works standalone;
forms compose both)
---
## 3. Implementation state — MVP reference scenario is done
Workplans **CE-WP-0001 through CE-WP-0005** are all `status: done`:
| Workplan | Delivers |
|----------|----------|
| CE-WP-0001 | Scaffold, folder partitions, ESLint boundary rules, normalization, fixtures |
| CE-WP-0002 | PDF review slice — engine types, anchor, source ingest, viewer, sidebar |
| CE-WP-0003 | Form binding + visual guide (rect registry, SVG overlay) |
| CE-WP-0004 | Citation card export (Markdown + HTML) |
| CE-WP-0005 | Named sessions, arbitrary PDF upload, ZIP export/import |
The PRD §20 reference scenario is covered end-to-end for **PDF**:
1. Create collection/session
2. Upload PDF
3. Select passage → annotation → evidence item
4. Open side-by-side form
5. Link evidence to field
6. Focus field → coordinated highlight + visual guide
7. Export citation card
Test coverage includes 7 integration tests (PRD scenario, forms flows, overlay, citation
export, session ZIP round-trip, anchor/source roundtrip) plus extensive unit tests per
subsystem folder. Recent git activity (June 2026) shows active polish on PDF text-layer
positioning and session UX.
Boundary enforcement is real: `eslint-plugin-boundaries` guards the
`src/{shared,engine,anchor,source,binder,work,app}/` dependency graph described in
`DependencyMap.md`.
---
## 4. Gap analysis — vision vs. current code
Against the full product vision in the PRD and subsystem INTENTs, significant pieces
remain **designed but not built**:
| Capability | PRD / INTENT status | Code status |
|------------|---------------------|-------------|
| **PDF review & evidence capture** | Primary MVP | **Implemented** |
| **Evidence-backed forms + visual guide** | Primary MVP | **Implemented** |
| **Citation card export** | Primary MVP | **Implemented** |
| **Session portability (ZIP)** | Demo enhancement | **Implemented** (CE-WP-0005) |
| **Markdown / HTML documents** | Primary goal (FR) | **Not started**`src/source/` is PDF-only |
| **Citation recovery mode** | Third product mode | **Not started**`CitationRecoveryAttempt` in contracts/ids only |
| **Document review status workflow** | `citation-work` INTENT | **Not wired**`reviewStatus` enum in contracts, no UI usage |
| **External source discovery** | Future / privacy-sensitive | **Deferred** (correct per PRD non-goals) |
| **Sister repo extraction** | Post-MVP | **Not started** — all code still in umbrella |
| **Monorepo vs. polyrepo decision** | ADR-0002 | **Still blank** — blocks clean extraction |
**Housekeeping debt:** `workplans/README.md` is stale (still lists CE-WP-0001..0004 as
`todo`); the individual workplan files correctly show `done`.
---
## 5. Per-repo assessment
### 5.1 citation-evidence — healthy, past MVP baseline
**Strengths:** Working reference app, enforced architecture, rich documentation, completed
Ralph workplans, contracts that sister repos can defer to.
**Risks:** Umbrella carries all complexity; extraction strategy undecided; PDF-only
implementation may hide format-neutral claims until HTML/Markdown adapters land; citation
recovery is a large remaining vertical with no code yet.
**Verdict:** The **center of gravity** of the family. This is where all meaningful
engineering lives today.
### 5.2 Sister repos (engine, anchor, source, work, binder) — scaffolded placeholders
**Strengths:** Excellent `INTENT.md` + `README.md` that correctly point upstream; LICENSE
and git remotes in place; boundaries pre-negotiated via umbrella wiki.
**Gaps:** No `package.json`, no source, no CI, no published packages. They are **boundary
documents**, not runnable libraries.
**Verdict:** Ready as **extraction targets**, not as independent products. Extraction should
follow ADR-0002 resolution and a deliberate `git mv` + package cut per README.
---
## 6. Strategic read
The family is in a **deliberate transitional architecture**:
```text
Phase A (complete): Design six-repo boundaries + build MVP in umbrella
Phase B (current): Harden PDF path, demo UX, contracts via real use
Phase C (next): Format expansion (MD/HTML) and/or citation recovery
Phase D (later): Extract subsystems to sister repos
```
Compared to the original phased plan in `history/2026-05-24-initial-assessment.md`, the
project has **skipped ahead**: Phase 1 (PDF vertical slice) and Phase 2 (form binding)
are done, plus demo/session portability. Phase 3 (format expansion) and Phase 4 (local
citation recovery) have **not** started.
The INTENT documents describe a mature, agent-friendly architecture. The code validates the
**hardest integration path** (PDF selection → durable selectors → form binding → visual
guide → export). What remains is mostly **breadth** (more formats, recovery mode) and
**structural** (extraction, packaging).
---
## 7. Recommended priorities
1. **Update `workplans/README.md`** to reflect CE-WP-0001..0005 as done; add CE-WP-0006
for the next vertical (Markdown adapter or local citation recovery — pick one).
2. **Resolve ADR-0002** before any extraction — monorepo workspaces vs. published
packages affects everything downstream.
3. **Either** expand formats (validates "format-neutral" claim) **or** build citation
recovery (validates third product mode) — doing both in parallel would split focus.
4. **Extract `citation-engine` first** when ready — it is the leaf node every other repo
depends on; `shared/` + `engine/` are the most stable slices.
---
## 8. Bottom line
The citation family is **well-architected on paper and materially implemented in one
place**. The six `INTENT.md` files form a consistent, boundary-aware design; the umbrella
repo has delivered a working PDF-centric MVP with tests and enforced dependency rules. The
five sister repos are **correctly empty** during umbrella-first MVP — they are extraction
targets, not lagging implementations.
**Overall state:** design maturity high, implementation maturity solid for PDF MVP,
extraction maturity low, product breadth ~half of full PRD vision.
The main open question is what comes next — format expansion, citation recovery, or
subsystem extraction.

View File

@@ -19,6 +19,7 @@
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
"dependencies": { "dependencies": {
"@citation-evidence/engine": "link:../citation-engine",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"pdfjs-dist": "^4.4.168", "pdfjs-dist": "^4.4.168",
"react": "^18.3.1", "react": "^18.3.1",
@@ -42,6 +43,7 @@
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.0.0", "typescript-eslint": "^8.0.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vite-plugin-static-copy": "^2",
"vitest": "^2.0.5" "vitest": "^2.0.5"
} }
} }

194
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@citation-evidence/engine':
specifier: link:../citation-engine
version: link:../citation-engine
jszip: jszip:
specifier: ^3.10.1 specifier: ^3.10.1
version: 3.10.1 version: 3.10.1
@@ -72,6 +75,9 @@ importers:
vite: vite:
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.21(@types/node@20.19.41) version: 5.4.21(@types/node@20.19.41)
vite-plugin-static-copy:
specifier: ^2
version: 2.3.2(vite@5.4.21(@types/node@20.19.41))
vitest: vitest:
specifier: ^2.0.5 specifier: ^2.0.5
version: 2.1.9(@types/node@20.19.41)(happy-dom@20.9.0) version: 2.1.9(@types/node@20.19.41)(happy-dom@20.9.0)
@@ -477,6 +483,18 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
'@nodelib/fs.stat@2.0.5':
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
'@nodelib/fs.walk@1.2.8':
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@nolyfill/is-core-module@1.0.39': '@nolyfill/is-core-module@1.0.39':
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
@@ -1374,6 +1392,10 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1432,6 +1454,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.14: brace-expansion@1.1.14:
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
@@ -1483,6 +1509,10 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1759,12 +1789,19 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-json-stable-stringify@2.1.0: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-levenshtein@2.0.6: fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1797,6 +1834,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
fs-extra@11.3.5:
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
engines: {node: '>=14.14'}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1839,6 +1880,10 @@ packages:
get-tsconfig@4.14.0: get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
glob-parent@6.0.2: glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -1859,6 +1904,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
happy-dom@20.9.0: happy-dom@20.9.0:
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -1928,6 +1976,10 @@ packages:
resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-boolean-object@1.2.2: is-boolean-object@1.2.2:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2058,6 +2110,9 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jszip@3.10.1: jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
@@ -2107,10 +2162,18 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
micromatch@4.0.7: micromatch@4.0.7:
resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
minimatch@10.2.5: minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -2145,6 +2208,10 @@ packages:
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2193,6 +2260,10 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-map@7.0.4:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -2262,6 +2333,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
re-resizable@6.11.2: re-resizable@6.11.2:
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
peerDependencies: peerDependencies:
@@ -2339,6 +2413,10 @@ packages:
readable-stream@2.3.8: readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2364,11 +2442,18 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hasBin: true hasBin: true
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rollup@4.60.4: rollup@4.60.4:
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safe-array-concat@1.1.4: safe-array-concat@1.1.4:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -2572,6 +2657,10 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
@@ -2612,6 +2701,12 @@ packages:
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true hasBin: true
vite-plugin-static-copy@2.3.2:
resolution: {integrity: sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vite@5.4.21: vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -3075,6 +3170,18 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true optional: true
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
'@nodelib/fs.stat@2.0.5': {}
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@pdf-lib/standard-fonts@1.0.0': '@pdf-lib/standard-fonts@1.0.0':
@@ -3942,6 +4049,11 @@ snapshots:
ansi-styles@5.2.0: {} ansi-styles@5.2.0: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.2
argparse@2.0.1: {} argparse@2.0.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
@@ -4016,6 +4128,8 @@ snapshots:
baseline-browser-mapping@2.10.32: {} baseline-browser-mapping@2.10.32: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.14: brace-expansion@1.1.14:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@@ -4075,6 +4189,18 @@ snapshots:
check-error@2.1.3: {} check-error@2.1.3: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
@@ -4448,10 +4574,22 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.8
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
fastq@1.20.1:
dependencies:
reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -4480,6 +4618,12 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
fs-extra@11.3.5:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -4530,6 +4674,10 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
glob-parent@6.0.2: glob-parent@6.0.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@@ -4545,6 +4693,8 @@ snapshots:
gopd@1.2.0: {} gopd@1.2.0: {}
graceful-fs@4.2.11: {}
happy-dom@20.9.0: happy-dom@20.9.0:
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
@@ -4618,6 +4768,10 @@ snapshots:
dependencies: dependencies:
has-bigints: 1.1.0 has-bigints: 1.1.0
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-boolean-object@1.2.2: is-boolean-object@1.2.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -4738,6 +4892,12 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
jszip@3.10.1: jszip@3.10.1:
dependencies: dependencies:
lie: 3.3.0 lie: 3.3.0
@@ -4788,11 +4948,18 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
micromatch@4.0.7: micromatch@4.0.7:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.2 picomatch: 2.3.2
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.2
minimatch@10.2.5: minimatch@10.2.5:
dependencies: dependencies:
brace-expansion: 5.0.6 brace-expansion: 5.0.6
@@ -4820,6 +4987,8 @@ snapshots:
node-releases@2.0.46: {} node-releases@2.0.46: {}
normalize-path@3.0.0: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -4885,6 +5054,8 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
p-map@7.0.4: {}
pako@1.0.11: {} pako@1.0.11: {}
parent-module@1.0.1: parent-module@1.0.1:
@@ -4944,6 +5115,8 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {}
re-resizable@6.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): re-resizable@6.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@@ -5045,6 +5218,10 @@ snapshots:
string_decoder: 1.1.1 string_decoder: 1.1.1
util-deprecate: 1.0.2 util-deprecate: 1.0.2
readdirp@3.6.0:
dependencies:
picomatch: 2.3.2
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -5085,6 +5262,8 @@ snapshots:
path-parse: 1.0.7 path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0 supports-preserve-symlinks-flag: 1.0.0
reusify@1.1.0: {}
rollup@4.60.4: rollup@4.60.4:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -5116,6 +5295,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.4 '@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3 fsevents: 2.3.3
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
safe-array-concat@1.1.4: safe-array-concat@1.1.4:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -5352,6 +5535,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
universalify@2.0.1: {}
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
dependencies: dependencies:
napi-postinstall: 0.3.4 napi-postinstall: 0.3.4
@@ -5424,6 +5609,15 @@ snapshots:
- supports-color - supports-color
- terser - terser
vite-plugin-static-copy@2.3.2(vite@5.4.21(@types/node@20.19.41)):
dependencies:
chokidar: 3.6.0
fast-glob: 3.3.3
fs-extra: 11.3.5
p-map: 7.0.4
picocolors: 1.1.1
vite: 5.4.21(@types/node@20.19.41)
vite@5.4.21(@types/node@20.19.41): vite@5.4.21(@types/node@20.19.41):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5

12
registry/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Capability Registry
Markdown-first capability index for federation and reuse planning.
## Authoring
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
2. Add the row to `indexes/capabilities.yaml`.
3. Run `reuse-surface validate` from a checkout with the CLI installed.
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
Federation contract: reuse-surface `docs/RegistryFederation.md`.

View File

View File

@@ -0,0 +1,121 @@
---
id: capability.infotech.citation-evidence-workspace
name: Citation Evidence Workspace
summary: Document-centered evidence workspace for capturing, managing, presenting, and re-opening citations
— the umbrella application over the citation-evidence six-package design.
owner: citation-evidence
status: draft
domain: infotech
tags:
- citation
- evidence
- workspace
maturity:
discovery:
current: D4
target: D5
confidence: medium
rationale: INTENT.md, wiki/ArchitectureOverview.md, wiki/SharedContracts.md, wiki/DependencyMap.md,
and ADRs under docs/decisions/ describe the umbrella-first six-package design and partition boundaries
in detail.
availability:
current: A1
target: A3
confidence: medium
rationale: Vite/TS app runnable locally (`npm run dev`/`build`); depends on citation-engine via a
local workspace link; no hosted deployment yet.
external_evidence:
completeness:
level: C1
confidence: low
basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations:
- umbrella app wiring citation-engine into a usable workspace
- documented partition boundaries for future package extraction
broken_expectations: []
out_of_scope_expectations: []
reliability:
level: R0
confidence: low
basis: consumer_quality_signals
known_reliability_risks:
- most subsystem partitions still live under src/ pending extraction; only citation-engine has been
extracted so far
discovery:
intent: Give users a working evidence workspace today while the six-package design is incrementally
extracted, keeping citation-engine as the stable shared core.
includes:
- document-centered evidence capture, management, and presentation UI
- wiring to citation-engine
excludes:
- subsystems not yet extracted into their own packages (see wiki/DependencyMap.md)
assumptions: []
use_cases: []
research_memos: []
availability:
current_level: A1
target_level: A3
current_artifacts:
- Vite/TS application, runnable via `npm run dev`
target_artifacts: []
consumption_modes:
- application (local dev server)
relations:
depends_on: []
supports: []
related_to: []
evidence:
documentation:
- INTENT.md
- wiki/ArchitectureOverview.md
- wiki/SharedContracts.md
tests:
- tests/
- vitest.config.ts
consumer_feedback: []
bug_reports: []
incidents: []
consumer_guidance:
recommended_for:
- evaluating or prototyping the citation-evidence workspace UI
not_recommended_for:
- production deployment (no hosted build/deployment artifact yet)
known_limitations:
- umbrella-first MVP; five of six planned packages remain unextracted
promotion_history: []
---
# Citation Evidence Workspace
## Overview
`citation-evidence` is the umbrella workspace application over a planned six-package design for document-centered citation evidence. `citation-engine` has already been extracted as the shared domain core; remaining partitions stay under `src/` pending extraction.
## Assessment notes
### Discovery
INTENT.md, wiki/ArchitectureOverview.md, wiki/SharedContracts.md, wiki/DependencyMap.md, and ADRs under docs/decisions/ describe the umbrella-first six-package design and partition boundaries in detail.
### Availability
Vite/TS app runnable locally (`npm run dev`/`build`); depends on citation-engine via a local workspace link; no hosted deployment yet.
### Completeness
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
(reuse-surface). No external consumer feedback exists yet; levels reflect
scope-vs-intent documentation quality, not internal code quality.
### Reliability
No production consumer telemetry exists yet; reliability level is
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
## Promotion checklist
- [x] ID follows `capability.<domain>.<name>` pattern
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
- [x] `external_evidence` is populated separately from `maturity`
- [ ] Relations reference valid capability IDs (none yet)
- [x] Index entry added in `registry/indexes/capabilities.yaml`

View File

@@ -0,0 +1,19 @@
version: 1
updated: '2026-07-06'
domain: helix_forge
capabilities:
- id: capability.infotech.citation-evidence-workspace
name: Citation Evidence Workspace
summary: Document-centered evidence workspace for capturing, managing, presenting, and re-opening citations
— the umbrella application over the citation-evidence six-package design.
vector: D4 / A1 / C1 / R0
domain: infotech
status: draft
owner: citation-evidence
path: registry/capabilities/capability.infotech.citation-evidence-workspace.md
tags:
- citation
- evidence
- workspace
consumption_modes:
- application (local dev server)

162
scripts/check-install.sh Executable file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Verify that citation-evidence is installed consistently with its ecosystem
# layout. citation-evidence is the umbrella repo; @citation-evidence/engine
# must resolve from a sibling citation-engine checkout (see CE-WP-0009).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ENGINE_SIBLING="$ROOT/../citation-engine"
ENGINE_DEP="link:../citation-engine"
ENGINE_PKG="@citation-evidence/engine"
errors=0
warnings=0
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
fail() { printf ' \033[31m✗\033[0m %s\n' "$1"; errors=$((errors + 1)); }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; warnings=$((warnings + 1)); }
hint() { printf ' → %s\n' "$1"; }
section() {
printf '\n%s\n' "$1"
}
fix_clone_engine() {
hint "Clone citation-engine as a sibling of this repo:"
hint " cd $(dirname "$ROOT")"
hint " git clone <citation-engine-remote-url> citation-engine"
hint "Expected layout:"
hint " $(dirname "$ROOT")/citation-evidence/ (this repo)"
hint " $(dirname "$ROOT")/citation-engine/ (required sibling)"
}
printf 'citation-evidence install consistency check\n'
printf 'Umbrella root: %s\n' "$ROOT"
section "Required sibling: citation-engine"
if [[ -d "$ENGINE_SIBLING" ]]; then
ok "citation-engine directory exists at ../citation-engine"
else
fail "citation-engine not found at ../citation-engine"
fix_clone_engine
fi
if [[ -f "$ENGINE_SIBLING/package.json" ]]; then
ok "citation-engine/package.json present"
engine_name="$(node -e "
const p = require('$ENGINE_SIBLING/package.json');
process.stdout.write(p.name ?? '');
" 2>/dev/null || true)"
if [[ "$engine_name" == "$ENGINE_PKG" ]]; then
ok "citation-engine package name is $ENGINE_PKG"
else
fail "citation-engine package name is '${engine_name:-<missing>}' (expected $ENGINE_PKG)"
hint "Check that ../citation-engine is the correct repository."
fi
elif [[ -d "$ENGINE_SIBLING" ]]; then
fail "citation-engine/package.json missing"
hint "../citation-engine does not look like a valid checkout."
fi
for path in src/shared/index.ts src/engine/index.ts; do
if [[ -f "$ENGINE_SIBLING/$path" ]]; then
ok "citation-engine/$path present"
elif [[ -d "$ENGINE_SIBLING" ]]; then
fail "citation-engine/$path missing"
hint "Run 'git pull' in ../citation-engine or check out a complete tree."
fi
done
section "package.json link dependency"
declared="$(node -e "
const p = require('$ROOT/package.json');
const v = p.dependencies?.['$ENGINE_PKG'];
process.stdout.write(v ?? '');
" 2>/dev/null || true)"
if [[ "$declared" == "$ENGINE_DEP" ]]; then
ok "package.json declares \"$ENGINE_PKG\": \"$ENGINE_DEP\""
else
fail "package.json should declare \"$ENGINE_PKG\": \"$ENGINE_DEP\" (found: ${declared:-<missing>})"
fi
section "node_modules resolution"
linked="$ROOT/node_modules/@citation-evidence/engine"
if [[ -e "$linked" ]]; then
ok "node_modules/@citation-evidence/engine exists"
real="$(cd "$linked" && pwd -P 2>/dev/null || true)"
expected="$(cd "$ENGINE_SIBLING" 2>/dev/null && pwd -P || true)"
if [[ -n "$real" && -n "$expected" && "$real" == "$expected" ]]; then
ok "link resolves to ../citation-engine"
elif [[ -n "$real" && -n "$expected" ]]; then
fail "node_modules link points to $real (expected $expected)"
hint "Run 'pnpm install' from citation-evidence after fixing the sibling checkout."
fi
else
fail "node_modules/@citation-evidence/engine not found"
hint "Run 'pnpm install' from the citation-evidence root."
if [[ ! -d "$ENGINE_SIBLING" ]]; then
fix_clone_engine
fi
fi
section "Toolchain"
if command -v node >/dev/null 2>&1; then
node_ver="$(node -v | sed 's/^v//')"
ok "node $node_ver available"
if [[ -f "$ROOT/.nvmrc" ]]; then
want="$(tr -d '[:space:]' < "$ROOT/.nvmrc")"
node_major_minor="$(printf '%s' "$node_ver" | cut -d. -f1-2)"
want_major_minor="$(printf '%s' "$want" | cut -d. -f1-2)"
if [[ "$node_major_minor" == "$want_major_minor" ]]; then
ok "node version matches .nvmrc ($want)"
else
warn "node $node_ver does not match .nvmrc ($want) — use nvm/fnm to switch"
hint " nvm use # or: fnm use"
fi
fi
else
fail "node not found on PATH"
hint "Install Node 20 LTS (see .nvmrc)."
fi
if command -v pnpm >/dev/null 2>&1; then
ok "pnpm $(pnpm -v) available"
else
fail "pnpm not found on PATH"
hint "Enable corepack: corepack enable && corepack prepare pnpm@9.15.0 --activate"
fi
section "Optional sister repos (INTENT-only during MVP)"
parent="$(dirname "$ROOT")"
for repo in evidence-anchor evidence-source evidence-binder citation-work; do
if [[ -f "$parent/$repo/INTENT.md" ]]; then
ok "$repo checked out (optional)"
else
warn "$repo not present at ../$repo (optional — not required to run the umbrella)"
fi
done
section "Stale in-repo engine copies"
if [[ -d "$ROOT/src/shared" || -d "$ROOT/src/engine" ]]; then
fail "src/shared/ or src/engine/ still present in citation-evidence (removed in CE-WP-0009)"
hint "Delete local copies; engine code must come from ../citation-engine only."
else
ok "no duplicate src/shared or src/engine in umbrella"
fi
printf '\n'
if [[ "$errors" -gt 0 ]]; then
printf '\033[31mInstall check failed (%d error(s), %d warning(s)).\033[0m\n' "$errors" "$warnings"
printf 'Fix the items above, then run: pnpm install && make check-install\n'
exit 1
fi
if [[ "$warnings" -gt 0 ]]; then
printf '\033[33mInstall check passed with %d warning(s).\033[0m\n' "$warnings"
else
printf '\033[32mInstall check passed.\033[0m\n'
fi
exit 0

View File

@@ -0,0 +1,59 @@
/*
* Debug overlay for PDF text layer alignment.
*
* The text layer is normally invisible (`opacity: 0`) and selectable.
* When `.ce-debug-textlayer` is on a parent, every text node becomes a
* light grey box so it's obvious where text is selectable and where it
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
* text-layer shift caused by font fallbacks.
*
* Light grey was chosen so the debug overlay does not clash with the
* citation-yellow used for evidence highlights (see highlight-styles.css).
*
* Toggle via the "Debug text layer" entry in SessionMenu.
*/
.ce-debug-textlayer .textLayer {
outline: 2px dashed rgba(120, 120, 120, 0.55);
background: rgba(120, 120, 120, 0.06);
}
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
descendant so the entire selectable area is visible regardless of how
the renderer nested things. */
.ce-debug-textlayer .textLayer * {
background: rgba(170, 170, 170, 0.4) !important;
color: rgba(40, 40, 40, 0.85) !important;
opacity: 1 !important;
outline: 1px solid rgba(100, 100, 100, 0.35);
}
/* Dim the canvas-rendered layer slightly so the debug overlay stands
out by contrast. */
.ce-debug-textlayer canvas {
opacity: 0.4;
}
/*
* Layer-visibility toggles. Each `.ce-hide-<layer>` class is applied
* to the same viewer-wrapper element so a single parent can hide any
* combination of layers. Useful for diagnosing layer stacking issues
* (e.g. "is the textLayer covering the canvas?") by elimination.
*/
.ce-hide-canvas canvas {
display: none !important;
}
.ce-hide-text-layer .textLayer {
display: none !important;
}
.ce-hide-annotation-layer .annotationLayer,
.ce-hide-annotation-layer .annotationEditorLayer {
display: none !important;
}
.ce-hide-xfa-layer .xfaLayer {
display: none !important;
}

View File

@@ -0,0 +1,38 @@
/*
* Evidence highlight styling — matches the sidebar's "evidence card"
* palette so the viewer and the sidebar speak the same visual language.
*
* .TextHighlight__part inactive highlight (light yellow fill,
* thin amber border)
* .TextHighlight--active … the currently-focused evidence — same
* fill, thicker border
*
* The "active" class is applied by the spike viewer when the parent
* wrapper is marked with `data-ce-active="true"` so a single
* `activeAnnotationId` prop drives the entire viewer's focus state
* without per-highlight component coupling.
*
* We override the library's red `--scrolledTo` box-shadow so an
* activation doesn't flash a red ring that doesn't match the palette.
*/
.TextHighlight__part {
background: #fff8d6 !important;
outline: 1px solid #e0c050 !important;
outline-offset: 0;
cursor: pointer;
transition: outline 0.15s ease;
}
[data-ce-active="true"] .TextHighlight__part {
outline: 3px solid #b78b1c !important;
background: #fff5b8 !important;
}
/* The library applies `--scrolledTo` after a programmatic scroll. We
override its red box-shadow so the "you just landed on this" cue
sticks with the yellow palette. The thicker border from
`data-ce-active` already conveys focus. */
.TextHighlight--scrolledTo .TextHighlight__part {
box-shadow: none !important;
}

View File

@@ -14,7 +14,15 @@
* will build the real PDFViewerAdapter on top of this lessons-learned. * will build the real PDFViewerAdapter on top of this lessons-learned.
*/ */
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import { import {
PdfHighlighter, PdfHighlighter,
PdfLoader, PdfLoader,
@@ -26,12 +34,22 @@ import {
type PdfSelection, type PdfSelection,
type ScaledPosition, type ScaledPosition,
} from "react-pdf-highlighter-plus"; } from "react-pdf-highlighter-plus";
// pdfjs-dist's own pdf_viewer.css is the authoritative source for
// text-layer positioning. The version bundled with
// react-pdf-highlighter-plus is a minimal *override* (missing
// `position: absolute`, `inset: 0`, and PDF.js 4.x's
// `--scale-factor` handling) — load the real one first, then the
// library's overrides on top.
import "pdfjs-dist/web/pdf_viewer.css";
import "react-pdf-highlighter-plus/style/style.css"; import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css"; import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import "./highlight-styles.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@shared/selector"; import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types"; import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math"; import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
import { runScrollToHighlightJob } from "./scroll-job";
export { selectorsFromPdfCapture }; export { selectorsFromPdfCapture };
@@ -106,20 +124,33 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
}; };
} }
const ActiveAnnotationContext = createContext<string | null | undefined>(
undefined,
);
const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>(
undefined,
);
/** /**
* Trivial container that renders every stored highlight as a TextHighlight. * Stable highlight row — component type never changes so PdfHighlighter does
* For the spike, no editing tooling — just visual proof of "did the saved * not remount highlight layers on activation changes (which disturbs scroll).
* coordinates land on the right passage on the right page after reload?" * Active/focus styling reads from context instead.
*/ */
function SpikeHighlightContainer(): ReactNode { function SpikeHighlightContainer(): ReactNode {
const activeAnnotationId = useContext(ActiveAnnotationContext);
const onHighlightClicked = useContext(HighlightClickContext);
const { highlight, isScrolledTo } = useHighlightContainerContext(); const { highlight, isScrolledTo } = useHighlightContainerContext();
// Wrap the highlight in a data-tagged container so the visual-guide const isActive = activeAnnotationId === highlight.id;
// overlay's HighlightRectBridge can locate it via DOM query. The
// wrapper uses display: contents so it doesn't affect layout — the
// bounding rect is gathered from the live TextHighlight children at
// query time.
return ( return (
<div data-highlight-id={highlight.id} style={{ display: "contents" }}> <div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
e.stopPropagation();
onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer> <MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} /> <TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer> </MonitoredHighlightContainer>
@@ -166,6 +197,66 @@ export interface PdfSpikeViewerProps {
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
/** Annotation id to scroll to and highlight on mount, if any. */ /** Annotation id to scroll to and highlight on mount, if any. */
readonly scrollToAnnotationId?: string; readonly scrollToAnnotationId?: string;
/**
* Bumps when the same annotation should be re-scrolled (e.g. repeat click).
* Format is opaque — typically `${annotationId}:${version}`.
*/
readonly scrollRequestKey?: string;
/**
* Annotation id currently focused. The matching highlight gets a
* thicker border (see highlight-styles.css). `null`/undefined means
* "no active highlight".
*/
readonly activeAnnotationId?: string | null;
/**
* Called when the user clicks an existing highlight in the page.
* The receiver typically activates the matching evidence item.
*/
onHighlightClicked?(annotationId: string): void;
/**
* When true, paint the PDF text-layer spans in light grey so it's
* obvious which glyphs have a selectable text overlay and which are
* image-only. Also logs every onSelection event to the console.
*/
readonly debugTextLayer?: boolean;
/**
* Hide specific PDF.js layers so you can see what sits underneath.
* Helps diagnose layer-stacking issues (e.g. "is the text layer
* covering the canvas content?").
*/
readonly hideCanvas?: boolean;
readonly hideTextLayer?: boolean;
readonly hideAnnotationLayer?: boolean;
readonly hideXfaLayer?: boolean;
}
/**
* Nudge the PDF scroll container so `highlight` sits vertically centred.
* Best-effort: depends on highlight layer DOM being present after scroll.
*/
function centerHighlightInViewer(
utils: PdfHighlighterUtils,
highlight: Highlight,
attempt = 0,
): void {
const viewer = utils.getViewer();
const container = viewer?.container as HTMLElement | undefined;
if (!container) return;
const rect = getHighlightClientRects(highlight.id);
if (!rect) {
if (attempt < 12) {
requestAnimationFrame(() =>
centerHighlightInViewer(utils, highlight, attempt + 1),
);
}
return;
}
const cRect = container.getBoundingClientRect();
const highlightCenterY = rect.top + rect.height / 2;
const containerCenterY = cRect.top + cRect.height / 2;
const delta = highlightCenterY - containerCenterY;
if (Math.abs(delta) < 4) return;
container.scrollTop += delta;
} }
export interface StoredAnnotation { export interface StoredAnnotation {
@@ -181,47 +272,151 @@ export interface StoredAnnotation {
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed * - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/ */
export function PdfSpikeViewer(props: PdfSpikeViewerProps) { export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId } = props; const {
pdfUrl,
storedAnnotations,
onSelectionCaptured,
scrollToAnnotationId,
scrollRequestKey,
activeAnnotationId,
onHighlightClicked,
debugTextLayer,
hideCanvas,
hideTextLayer,
hideAnnotationLayer,
hideXfaLayer,
} = props;
const onHighlightClickedRef = useRef(onHighlightClicked);
onHighlightClickedRef.current = onHighlightClicked;
const handleHighlightClicked = useCallback((annotationId: string) => {
onHighlightClickedRef.current?.(annotationId);
}, []);
const pdfLoaderDocument = useMemo(
() => ({
url: pdfUrl,
// PdfLoader's effect depends on `document` by reference — must be
// stable across re-renders or the PDF reloads and scroll resets to top.
cMapUrl: "/cmaps/",
cMapPacked: true,
standardFontDataUrl: "/standard_fonts/",
}),
[pdfUrl],
);
const wrapperClasses = [
debugTextLayer ? "ce-debug-textlayer" : null,
hideCanvas ? "ce-hide-canvas" : null,
hideTextLayer ? "ce-hide-text-layer" : null,
hideAnnotationLayer ? "ce-hide-annotation-layer" : null,
hideXfaLayer ? "ce-hide-xfa-layer" : null,
]
.filter((c): c is string => c !== null)
.join(" ");
const utilsRef = useRef<PdfHighlighterUtils | null>(null); const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null); const scrollStateRef = useRef({ lastCompletedKey: null as string | null });
const highlights = useMemo<Highlight[]>(() => { const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = []; const out: Highlight[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const a of storedAnnotations) { for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors); const h = highlightFromSelectors(a.id, a.text, a.selectors);
if (h) out.push(h); if (h) out.push(h);
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
}
if (debugTextLayer) {
console.log("[ce] viewer highlights", {
in: storedAnnotations.length,
rendered: out.length,
rendered_detail: out.map((h) => ({
id: h.id,
page: h.position.boundingRect.pageNumber,
bounding: h.position.boundingRect,
rectCount: h.position.rects.length,
})),
skipped,
});
} }
return out; return out;
}, [storedAnnotations]); }, [storedAnnotations, debugTextLayer]);
const highlightsRef = useRef(highlights);
highlightsRef.current = highlights;
const highlightsSignature = useMemo(
() => highlights.map((h) => h.id).join(","),
[highlights],
);
// Re-render highlight layers when focus moves so `data-ce-active` updates.
const highlightsForViewer = useMemo(
() => highlights,
[highlights, activeAnnotationId],
);
useEffect(() => { useEffect(() => {
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return; const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
const utils = utilsRef.current; if (!requestKey || !scrollToAnnotationId) return;
const target = highlights.find((h) => h.id === scrollToAnnotationId); if (scrollStateRef.current.lastCompletedKey === requestKey) return;
if (!utils || !target) return;
utils.scrollToHighlight(target); if (debugTextLayer) {
setDidScroll(scrollToAnnotationId); console.log("[ce] scrollToAnnotation requested", {
}, [scrollToAnnotationId, highlights, didScroll]); id: scrollToAnnotationId,
requestKey,
utilsAvailable: !!utilsRef.current,
targetFound: !!highlightsRef.current.find((h) => h.id === scrollToAnnotationId),
knownIds: highlightsRef.current.map((h) => h.id),
});
}
return runScrollToHighlightJob(
{ requestKey, annotationId: scrollToAnnotationId },
{
getUtils: () => utilsRef.current,
findHighlight: (id) => highlightsRef.current.find((h) => h.id === id),
scrollToHighlight: (utils, target) => utils.scrollToHighlight(target),
centerHighlight: (utils, target) => centerHighlightInViewer(utils, target),
scheduleFrame: (fn) => requestAnimationFrame(fn),
},
scrollStateRef.current,
);
}, [scrollToAnnotationId, scrollRequestKey, highlightsSignature, debugTextLayer]);
return ( return (
<PdfLoader document={pdfUrl}> <div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader document={pdfLoaderDocument}>
{(pdfDocument) => ( {(pdfDocument) => (
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
<HighlightClickContext.Provider value={handleHighlightClicked}>
<PdfHighlighter <PdfHighlighter
pdfDocument={pdfDocument} pdfDocument={pdfDocument}
highlights={highlights} highlights={highlightsForViewer}
utilsRef={(u) => { utilsRef={(u) => {
utilsRef.current = u; utilsRef.current = u;
}} }}
onSelection={(selection) => { onSelection={(selection) => {
const capture = captureFromPdfSelection(selection); const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture); const selectors = selectorsFromPdfCapture(capture);
if (debugTextLayer) {
console.log("[ce] onSelection", {
text: capture.text,
page: capture.page,
rects: capture.rects,
selectorTypes: selectors.map((s) => s.type),
raw: selection,
});
}
onSelectionCaptured(capture, selectors); onSelectionCaptured(capture, selectors);
}} }}
> >
<SpikeHighlightContainer /> <SpikeHighlightContainer />
</PdfHighlighter> </PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)} )}
</PdfLoader> </PdfLoader>
</div>
); );
} }

View File

@@ -0,0 +1,73 @@
/**
* CE-WP-0008-T02 — scroll job retries until utils and highlight exist.
*/
import { describe, expect, it, vi } from "vitest";
import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
import { runScrollToHighlightJob } from "./scroll-job";
const TARGET = {
id: "ann_test",
type: "text",
content: { text: "quote" },
position: {
boundingRect: {
x1: 0,
y1: 0,
x2: 1,
y2: 1,
width: 1,
height: 1,
pageNumber: 2,
},
rects: [],
},
} as Highlight;
describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
it("retries until utils and highlight are available", () => {
const frames: Array<() => void> = [];
const scrollToHighlight = vi.fn();
const centerHighlight = vi.fn();
let utils: PdfHighlighterUtils | null = null;
const highlightRef: { current: Highlight | undefined } = { current: undefined };
const state = { lastCompletedKey: null as string | null };
const cancel = runScrollToHighlightJob(
{ requestKey: "ann_test:1", annotationId: "ann_test" },
{
getUtils: () => utils,
findHighlight: (id) => (id === "ann_test" ? highlightRef.current : undefined),
scrollToHighlight: (_u, target) => scrollToHighlight(target),
centerHighlight,
scheduleFrame: (fn) => {
frames.push(fn);
return frames.length;
},
maxAttempts: 5,
},
state,
);
expect(scrollToHighlight).not.toHaveBeenCalled();
// First two frames: still missing utils / highlight.
frames.shift()?.();
frames.shift()?.();
expect(scrollToHighlight).not.toHaveBeenCalled();
utils = { scrollToHighlight: vi.fn() } as unknown as PdfHighlighterUtils;
highlightRef.current = TARGET;
frames.shift()?.();
expect(scrollToHighlight).toHaveBeenCalledWith(TARGET);
expect(state.lastCompletedKey).toBe("ann_test:1");
frames.shift()?.();
expect(centerHighlight).toHaveBeenCalledWith(utils, TARGET);
cancel();
});
});

73
src/anchor/scroll-job.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Retryable scroll-to-highlight job for PdfSpikeViewer.
*
* The PDF highlighter's utils ref and highlight DOM are not always ready on
* the first effect tick (especially for page-2+ passages). This helper retries
* via rAF until both are available or attempts are exhausted.
*/
import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
export const DEFAULT_SCROLL_ATTEMPTS = 40;
export interface ScrollToHighlightJob {
readonly requestKey: string;
readonly annotationId: string;
}
export interface ScrollToHighlightDeps {
readonly getUtils: () => PdfHighlighterUtils | null;
readonly findHighlight: (annotationId: string) => Highlight | undefined;
readonly scrollToHighlight: (
utils: PdfHighlighterUtils,
target: Highlight,
) => void;
readonly centerHighlight: (
utils: PdfHighlighterUtils,
target: Highlight,
) => void;
readonly scheduleFrame: (fn: () => void) => number;
readonly maxAttempts?: number;
}
export interface ScrollToHighlightState {
lastCompletedKey: string | null;
}
/**
* Attempt scroll for `job`. Returns a cancel function. Sets
* `state.lastCompletedKey` only after a successful scroll.
*/
export function runScrollToHighlightJob(
job: ScrollToHighlightJob,
deps: ScrollToHighlightDeps,
state: ScrollToHighlightState,
): () => void {
let cancelled = false;
let attempt = 0;
const maxAttempts = deps.maxAttempts ?? DEFAULT_SCROLL_ATTEMPTS;
const tick = () => {
if (cancelled) return;
if (state.lastCompletedKey === job.requestKey) return;
const utils = deps.getUtils();
const target = deps.findHighlight(job.annotationId);
if (!utils || !target) {
if (attempt < maxAttempts) {
attempt += 1;
deps.scheduleFrame(tick);
}
return;
}
deps.scrollToHighlight(utils, target);
state.lastCompletedKey = job.requestKey;
deps.scheduleFrame(() => deps.centerHighlight(utils, target));
};
tick();
return () => {
cancelled = true;
};
}

View File

@@ -24,6 +24,7 @@ import {
EngineProvider, EngineProvider,
SessionProvider, SessionProvider,
useActiveSession, useActiveSession,
useActiveSessionId,
useEngine, useEngine,
usePdfByteStore, usePdfByteStore,
useSessionByteStoreRegistry, useSessionByteStoreRegistry,
@@ -33,6 +34,8 @@ import {
useSessionVersionBumper, useSessionVersionBumper,
} from "@work/index"; } from "@work/index";
import { CaptureLinkPersister } from "./forms/CaptureLinkPersister";
import { loadCaptureState } from "./forms/capture-persistence";
import { FormsApp } from "./forms/FormsApp"; import { FormsApp } from "./forms/FormsApp";
import { ReviewLayout } from "./ReviewLayout"; import { ReviewLayout } from "./ReviewLayout";
@@ -158,9 +161,35 @@ function ActiveAppFrame({
function SessionScopedTree({ mode }: { mode: AppMode }) { function SessionScopedTree({ mode }: { mode: AppMode }) {
const engine = useEngine(); const engine = useEngine();
const sessionId = useActiveSessionId();
const restoredCapture = useMemo(
() => (sessionId ? loadCaptureState(sessionId) : null),
[sessionId],
);
if (!sessionId) return null;
return ( return (
<BinderProvider bus={engine.bus}> <BinderProvider
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />} bus={engine.bus}
{...(restoredCapture?.evidenceLinks
? { initialLinks: restoredCapture.evidenceLinks }
: {})}
>
<CaptureLinkPersister sessionId={sessionId} />
{mode === "forms" ? (
<FormsApp
sessionId={sessionId}
{...(restoredCapture?.formSchema
? { initialSchema: restoredCapture.formSchema }
: {})}
{...(restoredCapture?.fieldValues
? { initialFieldValues: restoredCapture.fieldValues }
: {})}
/>
) : (
<ReviewLayout upload={<UploadDropzone />} />
)}
</BinderProvider> </BinderProvider>
); );
} }
@@ -288,7 +317,7 @@ function ActiveTopBar({
const tabs = useMemo( const tabs = useMemo(
() => [ () => [
{ id: "review" as const, label: "Review" }, { id: "review" as const, label: "Review" },
{ id: "forms" as const, label: "Forms" }, { id: "forms" as const, label: "Capture" },
], ],
[], [],
); );

View File

@@ -0,0 +1,29 @@
/**
* Writes evidence links to per-session capture storage whenever the
* binder mutates links.
*/
import { useEffect } from "react";
import type { SessionId } from "@shared/ids";
import { useBinder } from "@binder/index";
import { useEngineEventTick } from "@work/index";
import { persistCapturePatch } from "./capture-persistence";
export function CaptureLinkPersister({ sessionId }: { sessionId: SessionId }) {
const { links } = useBinder();
const linkTick = useEngineEventTick("EvidenceLinkCreated");
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
const updateTick = useEngineEventTick("EvidenceLinkUpdated");
useEffect(() => {
void linkTick;
void unlinkTick;
void updateTick;
persistCapturePatch(sessionId, { evidenceLinks: links.list() });
}, [sessionId, links, linkTick, unlinkTick, updateTick]);
return null;
}

View File

@@ -1,33 +1,32 @@
/** /**
* FormsApp — the evidence-backed form mode for CE-WP-0003. * FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007).
* *
* Layout: * Layout (CE-WP-0007):
* *
* ┌────────────┬─────────────────┬─────────────┐ * ┌────────────┬─────────────────┬─────────────┐
* │ Collection │ FormRenderer │ ViewerShell * │ Collection │ ViewerShell │ FormPane
* │ │ (left) │ (right) │
* ├────────────┴─────────────────┴─────────────┤ * ├────────────┴─────────────────┴─────────────┤
* │ EvidenceStrip (bottom) │ * │ EvidenceStrip (bottom) │
* └────────────────────────────────────────────┘ * └────────────────────────────────────────────┘
* *
* Linking interaction (T05): * Linking: field must have focus; clicking evidence links directly.
* 1. User clicks an evidence card in the strip → it becomes "selected
* for linking" (highlighted; banner appears in the form pane).
* 2. User then clicks a form field. The field's `FormFieldActivated`
* event triggers `bindings.linkEvidenceToTarget(selected, field)`.
* 3. The selected-for-linking state clears; the field's link count
* chip increments.
*
* Active-evidence cycling (T06) and the visual guide overlay (T07) build
* on this composition without changing the link interaction.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EvidenceItem } from "@shared/evidence"; import type { EvidenceItem } from "@shared/evidence";
import type { AnnotationId, EvidenceItemId } from "@shared/ids"; import type { EvidenceLink } from "@shared/evidence-link";
import type { EvidenceItemId } from "@shared/ids";
import { Overlay, useActiveState, useBinder } from "@binder/index"; import {
Overlay,
useActiveState,
useBinder,
useRegisterRect,
} from "@binder/index";
import type { SessionId } from "@shared/ids";
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
import { import {
CollectionList, CollectionList,
ViewerShell, ViewerShell,
@@ -37,21 +36,133 @@ import {
useScrollToAnnotation, useScrollToAnnotation,
} from "@work/index"; } from "@work/index";
import { FormRenderer } from "@binder/FormRenderer"; import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer";
import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips"; import { persistCapturePatch } from "./capture-persistence";
import { DEMO_SCHEMA } from "./demo-schema"; import { DEMO_SCHEMA } from "./demo-schema";
import { HighlightRectBridge } from "./HighlightRectBridge"; import { HighlightRectBridge } from "./HighlightRectBridge";
export function FormsApp() { export type EvidenceStripFilter = "all" | "attached";
const STRIP_FILTER_EVENT = "citation-evidence:strip-filter";
function publishStripFilter(mode: EvidenceStripFilter) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode }));
}
function quotePreview(text: string, max = 80): string {
const t = text.trim();
return t.length > max ? `${t.slice(0, max)}` : t;
}
export interface FormsAppProps {
readonly sessionId: SessionId;
readonly initialSchema?: FormSchema;
readonly initialFieldValues?: Readonly<Record<string, string>>;
}
export function FormsApp({
sessionId,
initialSchema,
initialFieldValues,
}: FormsAppProps) {
const [schema, setSchema] = useState<FormSchema>(() =>
initialSchema
? { ...initialSchema, fields: [...initialSchema.fields] }
: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] },
);
const fieldLabels = useMemo(
() => new Map(schema.fields.map((f) => [f.id, f.label] as const)),
[schema],
);
const [fieldValues, setFieldValues] = useState<Record<string, string>>(
() => ({ ...(initialFieldValues ?? {}) }),
);
const [showAddFieldForm, setShowAddFieldForm] = useState(false);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
setFieldValues((prev) => ({ ...prev, [fieldId]: value }));
}, []);
useEffect(() => {
persistCapturePatch(sessionId, { formSchema: schema, fieldValues });
}, [sessionId, schema, fieldValues]);
const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => {
let max = 0;
for (const f of fields) {
const m = /^field_(\d+)$/.exec(f.id);
if (m) max = Math.max(max, Number(m[1]));
}
return `field_${max + 1}`;
}, []);
const handleConfirmAddField = useCallback(
(patch: FieldDefinitionPatch) => {
setSchema((prev) => {
const id = nextFieldId(prev.fields);
const n = prev.fields.length + 1;
const field: FormFieldSchema = {
id,
type: patch.type,
label: patch.label.length > 0 ? patch.label : `New field ${n}`,
};
return { ...prev, fields: [...prev.fields, field] };
});
setShowAddFieldForm(false);
},
[nextFieldId],
);
const handleSaveFieldEdit = useCallback(
(fieldId: string, patch: FieldDefinitionPatch) => {
setSchema((prev) => ({
...prev,
fields: prev.fields.map((f) =>
f.id === fieldId
? {
...f,
type: patch.type,
label: patch.label.length > 0 ? patch.label : f.label,
}
: f,
),
}));
setEditingFieldId(null);
},
[],
);
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}> <div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<CollectionList /> <CollectionList />
<FormPane />
<ViewerShell /> <ViewerShell />
<FormPane
schema={schema}
fieldValues={fieldValues}
onFieldValueChange={handleFieldValueChange}
showAddFieldForm={showAddFieldForm}
editingFieldId={editingFieldId}
onRequestAddField={() => {
setEditingFieldId(null);
setShowAddFieldForm(true);
}}
onConfirmAddField={handleConfirmAddField}
onCancelAddField={() => setShowAddFieldForm(false)}
onBeginEditField={(fieldId) => {
setShowAddFieldForm(false);
setEditingFieldId(fieldId);
}}
onSaveFieldEdit={handleSaveFieldEdit}
onCancelFieldEdit={() => setEditingFieldId(null)}
/>
</div> </div>
<EvidenceStrip /> <EvidenceStrip fieldLabels={fieldLabels} />
<ScrollBridge /> <ScrollBridge />
<HighlightRectBridge /> <HighlightRectBridge />
<Overlay /> <Overlay />
@@ -59,12 +170,6 @@ export function FormsApp() {
); );
} }
/**
* Bridge: the binder's active-state machine doesn't know how to scroll
* the viewer; the work-level `useScrollToAnnotation` does. This subscriber
* reads `activeAnnotationId` from the binder and calls `scrollTo` whenever
* it changes. Lives in app/ because that's where both subsystems meet.
*/
function ScrollBridge() { function ScrollBridge() {
const { state } = useActiveState(); const { state } = useActiveState();
const { scrollTo } = useScrollToAnnotation(); const { scrollTo } = useScrollToAnnotation();
@@ -76,88 +181,125 @@ function ScrollBridge() {
return null; return null;
} }
function FormPane() { function FormPane({
schema,
fieldValues,
onFieldValueChange,
showAddFieldForm,
editingFieldId,
onRequestAddField,
onConfirmAddField,
onCancelAddField,
onBeginEditField,
onSaveFieldEdit,
onCancelFieldEdit,
}: {
schema: FormSchema;
fieldValues: Readonly<Record<string, string>>;
onFieldValueChange: (fieldId: string, value: string) => void;
showAddFieldForm: boolean;
editingFieldId: string | null;
onRequestAddField: () => void;
onConfirmAddField: (patch: FieldDefinitionPatch) => void;
onCancelAddField: () => void;
onBeginEditField: (fieldId: string) => void;
onSaveFieldEdit: (fieldId: string, patch: FieldDefinitionPatch) => void;
onCancelFieldEdit: () => void;
}) {
const { document } = useActiveDocument(); const { document } = useActiveDocument();
const { bindings } = useBinder(); const { bindings } = useBinder();
const engine = useEngine(); const engine = useEngine();
const linkTick = useEngineEventTick("EvidenceLinkCreated"); const linkTick = useEngineEventTick("EvidenceLinkCreated");
const { state: activeState } = useActiveState(); const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
const { state: activeState, setActiveEvidence } = useActiveState();
const [selectedForLinking, setSelectedForLinking] = useState<EvidenceItemId | null>( useEffect(() => {
null, return engine.bus.on("FormFieldActivated", () => {
); publishStripFilter("attached");
});
}, [engine]);
useEffect(() => {
const target = activeState.activeTarget;
if (!target || activeState.activeEvidenceItemId) return;
const links = bindings.listEvidenceForTarget(target);
if (links.length === 0) return;
const item = engine.evidence.get(links[0]!.evidenceItemId);
if (!item) return;
setActiveEvidence(item.id, item.annotationIds[0] ?? null);
}, [
activeState.activeTarget,
activeState.activeEvidenceItemId,
bindings,
engine,
linkTick,
unlinkTick,
setActiveEvidence,
]);
// Compute per-field link counts. Re-derives on link create.
const linkCounts = useMemo<Record<string, number>>(() => { const linkCounts = useMemo<Record<string, number>>(() => {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
for (const field of DEMO_SCHEMA.fields) { for (const field of schema.fields) {
out[field.id] = bindings.listEvidenceForTarget({ out[field.id] = bindings.listEvidenceForTarget({
targetType: "form-field", targetType: "form-field",
targetId: field.id, targetId: field.id,
}).length; }).length;
} }
void linkTick; void linkTick;
void unlinkTick;
return out; return out;
}, [bindings, linkTick]); }, [schema.fields, bindings, linkTick, unlinkTick]);
// Compute chip items for the currently-active target. const linkHints = useMemo<Record<string, string>>(() => {
const activeChipItems = useMemo<readonly ActiveEvidenceChipsItem[]>(() => { const out: Record<string, string> = {};
if (!activeState.activeTarget) return []; for (const field of schema.fields) {
const links = bindings.listEvidenceForTarget(activeState.activeTarget); const links = bindings.listEvidenceForTarget({
return links targetType: "form-field",
.map((link): ActiveEvidenceChipsItem | null => { targetId: field.id,
const item = engine.evidence.get(link.evidenceItemId);
if (!item) return null;
const annotationId: AnnotationId | null = item.annotationIds[0] ?? null;
const annotation = annotationId ? engine.annotations.get(annotationId) : null;
return {
evidenceItemId: link.evidenceItemId,
annotationId,
quote: annotation?.quote ?? "(no quote)",
...(item.commentary ? { commentary: item.commentary } : {}),
};
})
.filter((c): c is ActiveEvidenceChipsItem => c !== null);
// linkTick is included so newly created links populate the chips
// without an explicit refresh.
}, [activeState.activeTarget, bindings, engine, linkTick]);
// Listen for FormFieldActivated and, if an evidence is staged, create
// the link. The state machine reduction (focus-target) happens in
// parallel via ActiveStateProvider's own handler.
useEffect(() => {
return engine.bus.on("FormFieldActivated", (event) => {
if (!selectedForLinking) return;
bindings.linkEvidenceToTarget({
evidenceItemId: selectedForLinking,
target: event.target,
}); });
setSelectedForLinking(null); if (links.length === 0) continue;
}); const item = engine.evidence.get(links[0]!.evidenceItemId);
}, [engine, bindings, selectedForLinking]); const ann = item?.annotationIds[0]
? engine.annotations.get(item.annotationIds[0])
: null;
const quote = ann?.quote ?? item?.commentary ?? "";
if (quote) out[field.id] = quotePreview(quote);
}
void linkTick;
void unlinkTick;
return out;
}, [schema.fields, bindings, engine, linkTick, unlinkTick]);
return ( return (
<main <main
style={{ style={{
flex: "1 1 0", flex: "0 0 320px",
minWidth: 320, minWidth: 320,
borderRight: "1px solid #ddd", borderLeft: "1px solid #ddd",
overflow: "auto", overflow: "auto",
display: "flex",
flexDirection: "column",
}} }}
> >
<SelectedBanner
selectedForLinking={selectedForLinking}
onClear={() => setSelectedForLinking(null)}
/>
{document ? ( {document ? (
<> <FormRenderer
<FormRenderer schema={DEMO_SCHEMA} linkCounts={linkCounts} /> schema={schema}
<ActiveEvidenceChips items={activeChipItems} /> values={fieldValues}
</> onValueChange={onFieldValueChange}
linkCounts={linkCounts}
linkHints={linkHints}
showAddFieldForm={showAddFieldForm}
onRequestAddField={onRequestAddField}
onConfirmAddField={onConfirmAddField}
onCancelAddField={onCancelAddField}
editingFieldId={editingFieldId}
onBeginEditField={onBeginEditField}
onSaveFieldEdit={onSaveFieldEdit}
onCancelFieldEdit={onCancelFieldEdit}
/>
) : ( ) : (
<EmptyHint /> <EmptyHint />
)} )}
<SelectionContext setSelected={setSelectedForLinking} />
</main> </main>
); );
} }
@@ -165,97 +307,114 @@ function FormPane() {
function EmptyHint() { function EmptyHint() {
return ( return (
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}> <p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
Pick a fixture from the collection list to start binding evidence. Pick a document from the collection to start capturing evidence links.
</p> </p>
); );
} }
function SelectedBanner({ function EvidenceStrip({
selectedForLinking, fieldLabels,
onClear,
}: { }: {
selectedForLinking: EvidenceItemId | null; fieldLabels: ReadonlyMap<string, string>;
onClear: () => void;
}) { }) {
if (!selectedForLinking) return null;
return (
<div
role="status"
aria-live="polite"
style={{
padding: 8,
background: "#fff4d6",
borderBottom: "1px solid #f0c040",
fontFamily: "system-ui, sans-serif",
fontSize: 12,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span>
Evidence staged for linking. Click a form field to link it, or{" "}
</span>
<button onClick={onClear} style={{ fontSize: 12, padding: "2px 8px" }}>
cancel
</button>
</div>
);
}
/**
* Bridges the strip's "stage this evidence" callback to the FormPane's
* local state. The strip lives in a sibling DOM subtree; rather than
* lifting `selectedForLinking` all the way up to FormsApp, we publish a
* setter into a module-scoped event target.
*
* Simpler than another context for one local handshake.
*/
const STAGED_EVENT = "citation-evidence:staged-for-linking";
function SelectionContext({
setSelected,
}: {
setSelected: (id: EvidenceItemId | null) => void;
}) {
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<EvidenceItemId | null>).detail;
setSelected(detail);
};
window.addEventListener(STAGED_EVENT, handler);
return () => window.removeEventListener(STAGED_EVENT, handler);
}, [setSelected]);
return null;
}
export function publishStagedForLinking(id: EvidenceItemId | null) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id }));
}
function EvidenceStrip() {
const engine = useEngine(); const engine = useEngine();
const { bindings } = useBinder();
const { document } = useActiveDocument(); const { document } = useActiveDocument();
const createTick = useEngineEventTick("EvidenceItemCreated"); const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated"); const updateTick = useEngineEventTick("EvidenceItemUpdated");
const linkTick = useEngineEventTick("EvidenceLinkCreated"); const linkTick = useEngineEventTick("EvidenceLinkCreated");
const { state: activeState } = useActiveState(); const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
const [stagedId, setStagedId] = useState<EvidenceItemId | null>(null); const { state: activeState, setActiveEvidence, clearActiveEvidence } =
useActiveState();
const items = useMemo<readonly EvidenceItem[]>(() => { const [userFilter, setUserFilter] = useState<EvidenceStripFilter>("all");
const [sessionFilter, setSessionFilter] = useState<EvidenceStripFilter | null>(
null,
);
const effectiveFilter = sessionFilter ?? userFilter;
useEffect(() => {
const handler = (e: Event) => {
setSessionFilter((e as CustomEvent<EvidenceStripFilter>).detail);
};
window.addEventListener(STRIP_FILTER_EVENT, handler);
return () => window.removeEventListener(STRIP_FILTER_EVENT, handler);
}, []);
useEffect(() => {
if (!activeState.activeTarget) {
setSessionFilter(null);
}
}, [activeState.activeTarget]);
const allItems = useMemo<readonly EvidenceItem[]>(() => {
if (!document) return []; if (!document) return [];
void createTick; void createTick;
void updateTick; void updateTick;
void linkTick; void linkTick;
void unlinkTick;
return engine.evidence.listByDocument(document.id); return engine.evidence.listByDocument(document.id);
}, [document, engine, createTick, updateTick, linkTick]); }, [document, engine, createTick, updateTick, linkTick, unlinkTick]);
const handleStage = (id: EvidenceItemId) => { const items = useMemo(() => {
const next = stagedId === id ? null : id; if (effectiveFilter !== "attached" || !activeState.activeTarget) {
setStagedId(next); return allItems;
publishStagedForLinking(next); }
}; const links = bindings.listEvidenceForTarget(activeState.activeTarget);
const ids = new Set(links.map((l) => l.evidenceItemId));
const attached = allItems.filter((item) => ids.has(item.id));
return attached.length > 0 ? attached : allItems;
}, [
allItems,
effectiveFilter,
activeState.activeTarget,
bindings,
linkTick,
unlinkTick,
]);
const tryLink = useCallback(
(evidenceItemId: EvidenceItemId, fieldId: string): boolean => {
const existing = bindings
.listEvidenceForTarget({ targetType: "form-field", targetId: fieldId })
.some((l) => l.evidenceItemId === evidenceItemId);
if (existing) return false;
bindings.linkEvidenceToTarget({
evidenceItemId,
target: { targetType: "form-field", targetId: fieldId },
});
return true;
},
[bindings],
);
const handleCardClick = useCallback(
(item: EvidenceItem) => {
const annId = item.annotationIds[0] ?? null;
setActiveEvidence(item.id, annId);
const target = activeState.activeTarget;
if (target?.targetType === "form-field") {
tryLink(item.id, target.targetId);
}
},
[activeState.activeTarget, setActiveEvidence, tryLink],
);
const handleUnlink = useCallback(
(link: EvidenceLink) => {
bindings.unlinkEvidence(link.id);
if (
activeState.activeEvidenceItemId === link.evidenceItemId &&
activeState.activeTarget?.targetType === link.targetType &&
activeState.activeTarget?.targetId === link.targetId
) {
clearActiveEvidence();
}
},
[bindings, activeState, clearActiveEvidence],
);
if (!document) return null; if (!document) return null;
@@ -267,56 +426,185 @@ function EvidenceStrip() {
background: "#fafafa", background: "#fafafa",
padding: 8, padding: 8,
display: "flex", display: "flex",
gap: 8, flexDirection: "column",
overflowX: "auto", gap: 6,
flex: "0 0 auto", flex: "0 0 auto",
minHeight: 100, minHeight: 100,
fontFamily: "system-ui, sans-serif", fontFamily: "system-ui, sans-serif",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
<span style={{ color: "#666" }}>Show:</span>
<FilterToggle
label="All"
active={effectiveFilter === "all"}
onClick={() => {
setUserFilter("all");
setSessionFilter(null);
}}
/>
<FilterToggle
label="Linked to field"
active={effectiveFilter === "attached"}
onClick={() => {
setUserFilter("attached");
setSessionFilter(null);
}}
/>
</div>
<div style={{ display: "flex", gap: 8, overflowX: "auto" }}>
{items.length === 0 && ( {items.length === 0 && (
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}> <p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
No evidence yet. Switch to Review mode to capture a passage. {effectiveFilter === "attached"
? "No evidence linked to the active field."
: "No evidence yet. Switch to Review mode to capture a passage."}
</p> </p>
)} )}
{items.map((item) => { {items.map((item) => (
<EvidenceStripCard
key={item.id}
item={item}
isActive={activeState.activeEvidenceItemId === item.id}
links={bindings.listTargetsForEvidence(item.id)}
fieldLabels={fieldLabels}
onClick={() => handleCardClick(item)}
onUnlink={handleUnlink}
/>
))}
</div>
</section>
);
}
function FilterToggle({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
border: active ? "1px solid #0050b3" : "1px solid #ccc",
background: active ? "#e8f0ff" : "white",
cursor: "pointer",
}}
>
{label}
</button>
);
}
function EvidenceStripCard({
item,
isActive,
links,
fieldLabels,
onClick,
onUnlink,
}: {
item: EvidenceItem;
isActive: boolean;
links: readonly EvidenceLink[];
fieldLabels: ReadonlyMap<string, string>;
onClick: () => void;
onUnlink: (link: EvidenceLink) => void;
}) {
const engine = useEngine();
const ref = useRef<HTMLDivElement>(null);
useRegisterRect("evidence-card", item.id, ref);
const firstAnn = item.annotationIds[0] const firstAnn = item.annotationIds[0]
? engine.annotations.get(item.annotationIds[0]) ? engine.annotations.get(item.annotationIds[0])
: null; : null;
const quote = firstAnn?.quote ?? "(no quote)"; const quote = firstAnn?.quote ?? "(no quote)";
const isStaged = stagedId === item.id;
const isActive = activeState.activeEvidenceItemId === item.id; const formLinks = links.filter((l) => l.targetType === "form-field");
return ( return (
<button <div
key={item.id} ref={ref}
onClick={() => handleStage(item.id)}
data-staged={isStaged ? "true" : "false"}
aria-current={isActive ? "true" : undefined}
style={{ style={{
position: "relative",
minWidth: 220, minWidth: 220,
maxWidth: 280, maxWidth: 280,
textAlign: "left", flexShrink: 0,
fontSize: 12, }}
padding: 8, >
border: isActive {formLinks.length > 0 && (
? "2px solid #0050b3" <div
: isStaged style={{
? "2px solid #f0a000" position: "absolute",
: "1px solid #ccc", top: 4,
background: isActive ? "#e8f0ff" : isStaged ? "#fff4d6" : "white", right: 4,
display: "flex",
gap: 2,
zIndex: 1,
}}
>
{formLinks.map((link) => {
const label = fieldLabels.get(link.targetId) ?? link.targetId;
return (
<button
key={link.id}
type="button"
title={`Linked to: ${label}. Click to remove link.`}
aria-label={`Remove link to ${label}`}
onClick={(e) => {
e.stopPropagation();
onUnlink(link);
}}
style={{
fontSize: 10,
lineHeight: 1,
padding: "2px 4px",
border: "1px solid #88a",
borderRadius: 3,
background: "#eef",
cursor: "pointer", cursor: "pointer",
}} }}
> >
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 100)}
{quote.length > 100 ? "…" : ""}&rdquo;
</div>
{item.commentary && (
<div style={{ color: "#333" }}>{item.commentary}</div>
)}
</button> </button>
); );
})} })}
</section> </div>
)}
<button
type="button"
onClick={onClick}
aria-current={isActive ? "true" : undefined}
style={{
width: "100%",
textAlign: "left",
fontSize: 12,
padding: 8,
border: isActive ? "2px solid #0050b3" : "1px solid #ccc",
background: isActive ? "#e8f0ff" : "white",
cursor: "pointer",
}}
>
<div
style={{
fontStyle: "italic",
marginBottom: 4,
paddingRight: formLinks.length ? 24 : 0,
}}
>
&ldquo;{quote.slice(0, 100)}
{quote.length > 100 ? "…" : ""}&rdquo;
</div>
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
</button>
</div>
); );
} }

View File

@@ -0,0 +1,96 @@
/**
* CE-WP-0008 — per-session capture state round-trip.
*/
import { describe, expect, it } from "vitest";
import type { EvidenceLink } from "@shared/evidence-link";
import type { EvidenceItemId, EvidenceLinkId, SessionId } from "@shared/ids";
import {
CAPTURE_STATE_VERSION,
captureStateKey,
defaultCaptureState,
loadCaptureState,
persistCapturePatch,
removeCaptureState,
saveCaptureState,
} from "./capture-persistence";
function memoryStorage(): Storage {
const map = new Map<string, string>();
return {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (k) => map.get(k) ?? null,
key: (i) => [...map.keys()][i] ?? null,
removeItem: (k) => void map.delete(k),
setItem: (k, v) => void map.set(k, v),
};
}
const SESSION = "sess_capture" as SessionId;
describe("capture-persistence", () => {
it("uses a per-session storage key", () => {
expect(captureStateKey(SESSION)).toBe(
"citation-evidence:session:sess_capture:capture-state:v1",
);
});
it("round-trips schema, field values, and links", () => {
const storage = memoryStorage();
const state = defaultCaptureState();
const withData = {
...state,
fieldValues: { summary: "Tenant owes arrears", deadline: "2026-12-15" },
evidenceLinks: [
{
id: "evlink_1" as EvidenceLinkId,
evidenceItemId: "evi_1" as EvidenceItemId,
targetType: "form-field",
targetId: "summary",
relation: "supports",
status: "candidate",
createdAt: "2026-06-08T00:00:00.000Z",
updatedAt: "2026-06-08T00:00:00.000Z",
} satisfies EvidenceLink,
],
};
saveCaptureState(SESSION, withData, storage);
const loaded = loadCaptureState(SESSION, storage);
expect(loaded?.version).toBe(CAPTURE_STATE_VERSION);
expect(loaded?.fieldValues).toEqual(withData.fieldValues);
expect(loaded?.evidenceLinks).toHaveLength(1);
expect(loaded?.formSchema.id).toBe("demo-form");
});
it("persistCapturePatch merges without dropping other fields", () => {
const storage = memoryStorage();
saveCaptureState(
SESSION,
{
...defaultCaptureState(),
fieldValues: { amount: "1200" },
evidenceLinks: [],
},
storage,
);
persistCapturePatch(SESSION, { fieldValues: { amount: "1500", summary: "Updated" } }, storage);
const loaded = loadCaptureState(SESSION, storage);
expect(loaded?.fieldValues).toEqual({ amount: "1500", summary: "Updated" });
});
it("removeCaptureState clears the key", () => {
const storage = memoryStorage();
saveCaptureState(SESSION, defaultCaptureState(), storage);
removeCaptureState(SESSION, storage);
expect(loadCaptureState(SESSION, storage)).toBeNull();
});
});

View File

@@ -0,0 +1,129 @@
/**
* Per-session Capture mode persistence (form schema, field values, links).
*
* Engine snapshots intentionally omit binder/app UI state. This module
* stores capture data beside the engine snapshot under a per-session
* localStorage key.
*/
import type { EvidenceLink } from "@shared/evidence-link";
import type { SessionId } from "@shared/ids";
import type { FormSchema } from "@binder/FormRenderer";
import { DEMO_SCHEMA } from "./demo-schema";
export const CAPTURE_STATE_VERSION = 1;
export interface CaptureStateSnapshot {
readonly version: number;
readonly formSchema: FormSchema;
readonly fieldValues: Readonly<Record<string, string>>;
readonly evidenceLinks: readonly EvidenceLink[];
}
export function captureStateKey(sessionId: SessionId): string {
return `citation-evidence:session:${sessionId}:capture-state:v1`;
}
export function defaultCaptureState(): CaptureStateSnapshot {
return {
version: CAPTURE_STATE_VERSION,
formSchema: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] },
fieldValues: {},
evidenceLinks: [],
};
}
function isFormSchema(value: unknown): value is FormSchema {
if (typeof value !== "object" || value === null) return false;
const o = value as Record<string, unknown>;
if (typeof o.id !== "string" || typeof o.title !== "string") return false;
if (!Array.isArray(o.fields)) return false;
return o.fields.every((f) => {
if (typeof f !== "object" || f === null) return false;
const field = f as Record<string, unknown>;
return (
typeof field.id === "string" &&
typeof field.label === "string" &&
(field.type === "text" || field.type === "textarea" || field.type === "date")
);
});
}
function parseCaptureState(raw: unknown): CaptureStateSnapshot | null {
if (typeof raw !== "object" || raw === null) return null;
const o = raw as Record<string, unknown>;
if (o.version !== CAPTURE_STATE_VERSION) return null;
if (!isFormSchema(o.formSchema)) return null;
if (typeof o.fieldValues !== "object" || o.fieldValues === null || Array.isArray(o.fieldValues)) {
return null;
}
const fieldValues: Record<string, string> = {};
for (const [k, v] of Object.entries(o.fieldValues as Record<string, unknown>)) {
if (typeof v === "string") fieldValues[k] = v;
}
if (!Array.isArray(o.evidenceLinks)) return null;
const evidenceLinks = o.evidenceLinks as EvidenceLink[];
return {
version: CAPTURE_STATE_VERSION,
formSchema: o.formSchema,
fieldValues,
evidenceLinks,
};
}
export function loadCaptureState(
sessionId: SessionId,
storage: Pick<Storage, "getItem"> = globalThis.localStorage,
): CaptureStateSnapshot | null {
if (typeof storage?.getItem !== "function") return null;
const raw = storage.getItem(captureStateKey(sessionId));
if (!raw) return null;
try {
return parseCaptureState(JSON.parse(raw) as unknown);
} catch {
return null;
}
}
export function saveCaptureState(
sessionId: SessionId,
state: CaptureStateSnapshot,
storage: Pick<Storage, "setItem"> = globalThis.localStorage,
): void {
if (typeof storage?.setItem !== "function") return;
try {
storage.setItem(captureStateKey(sessionId), JSON.stringify(state));
} catch (err) {
console.warn("saveCaptureState: write failed", err);
}
}
export function persistCapturePatch(
sessionId: SessionId,
patch: Partial<
Pick<CaptureStateSnapshot, "formSchema" | "fieldValues" | "evidenceLinks">
>,
storage: Pick<Storage, "getItem" | "setItem"> = globalThis.localStorage,
): void {
const current = loadCaptureState(sessionId, storage) ?? defaultCaptureState();
saveCaptureState(
sessionId,
{
version: CAPTURE_STATE_VERSION,
formSchema: patch.formSchema ?? current.formSchema,
fieldValues: patch.fieldValues ?? current.fieldValues,
evidenceLinks: patch.evidenceLinks ?? current.evidenceLinks,
},
storage,
);
}
export function removeCaptureState(
sessionId: SessionId,
storage: Pick<Storage, "removeItem"> = globalThis.localStorage,
): void {
if (typeof storage?.removeItem !== "function") return;
storage.removeItem(captureStateKey(sessionId));
}

View File

@@ -1,7 +1,16 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import * as pdfjs from "pdfjs-dist";
// Vite resolves `?url` to a bundled asset URL the browser can fetch.
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { App } from "./App"; import { App } from "./App";
// PDF.js needs a worker URL before any PDF is parsed. Set it once at app
// bootstrap so both the source-layer ingest (extract.ts) and the viewer
// adapter (PdfSpikeViewer) can open documents.
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
const container = document.getElementById("root"); const container = document.getElementById("root");
if (!container) throw new Error("#root not found"); if (!container) throw new Error("#root not found");

View File

@@ -4,10 +4,15 @@
* Inline name input + Create button. On success, navigates the hash to * 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 * 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. * launch (no sessions yet) and after the last session was deleted.
*
* Also surfaces a "Reset all data" affordance for users who want a
* clean slate without digging into devtools — wipes every
* `citation-evidence:*` key from localStorage and reloads.
*/ */
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { clearAllSessionData } from "@engine/index";
import { useSessionService } from "@work/index"; import { useSessionService } from "@work/index";
import { navigateTo } from "./routing"; import { navigateTo } from "./routing";
@@ -16,18 +21,39 @@ export function CreateFirstSession() {
const service = useSessionService(); const service = useSessionService();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pendingReset, setPendingReset] = useState(false);
const hasOthers = service.list().length > 0; const hasOthers = service.list().length > 0;
useEffect(() => {
if (!pendingReset) return;
const t = setTimeout(() => setPendingReset(false), 5000);
return () => clearTimeout(t);
}, [pendingReset]);
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
setError(null); setError(null);
try { try {
const created = service.create(name); const trimmed = name.trim();
const effective = trimmed.length === 0 ? service.nextDefaultName() : name;
const created = service.create(effective);
navigateTo({ sessionId: created.id, mode: "review" }); navigateTo({ sessionId: created.id, mode: "review" });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
}, [name, service]); }, [name, service]);
const handleResetAll = useCallback(() => {
if (!pendingReset) {
setPendingReset(true);
return;
}
clearAllSessionData();
if (typeof window !== "undefined") {
window.location.hash = "";
window.location.reload();
}
}, [pendingReset]);
return ( return (
<div <div
data-testid="empty-state" data-testid="empty-state"
@@ -99,6 +125,23 @@ export function CreateFirstSession() {
{error} {error}
</div> </div>
)} )}
<button
type="button"
onClick={handleResetAll}
data-testid="empty-state-reset-all"
title="Wipe every session, uploaded PDF, and annotation from this browser — then reload."
style={{
marginTop: 24,
fontSize: 11,
color: "#7a0000",
background: "transparent",
border: "none",
cursor: "pointer",
textDecoration: "underline",
}}
>
{pendingReset ? "Confirm — wipe everything?" : "Reset all data…"}
</button>
</div> </div>
); );
} }

View File

@@ -13,8 +13,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session"; import type { Session } from "@shared/session";
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index"; import {
useActiveSession,
useDebugFlag,
useSessionListTick,
useSessionService,
} from "@work/index";
import { clearAllSessionData } from "@engine/index";
import { navigateTo } from "./routing"; import { navigateTo } from "./routing";
@@ -28,6 +35,11 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
const service = useSessionService(); const service = useSessionService();
const tick = useSessionListTick(); const tick = useSessionListTick();
const active = useActiveSession(); const active = useActiveSession();
const [debugTextLayer, setDebugTextLayer] = useDebugFlag("textLayer");
const [hideCanvas, setHideCanvas] = useDebugFlag("hideCanvas");
const [hideTextLayer, setHideTextLayer] = useDebugFlag("hideTextLayer");
const [hideAnnotationLayer, setHideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
const [hideXfaLayer, setHideXfaLayer] = useDebugFlag("hideXfaLayer");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -35,6 +47,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [pendingDelete, setPendingDelete] = useState(false); const [pendingDelete, setPendingDelete] = useState(false);
/** Per-row delete confirmation: id of the session armed for delete. */
const [pendingRowDelete, setPendingRowDelete] = useState<SessionId | null>(null);
const [pendingResetAll, setPendingResetAll] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
@@ -60,12 +75,28 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
setCreating(false); setCreating(false);
setRenaming(false); setRenaming(false);
setPendingDelete(false); setPendingDelete(false);
setPendingRowDelete(null);
setPendingResetAll(false);
} }
}; };
window.addEventListener("mousedown", handler); window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler); return () => window.removeEventListener("mousedown", handler);
}, [open]); }, [open]);
// Auto-clear pending row-delete after a few seconds so the user
// doesn't accidentally double-click much later and lose data.
useEffect(() => {
if (!pendingRowDelete) return;
const t = setTimeout(() => setPendingRowDelete(null), 3000);
return () => clearTimeout(t);
}, [pendingRowDelete]);
useEffect(() => {
if (!pendingResetAll) return;
const t = setTimeout(() => setPendingResetAll(false), 5000);
return () => clearTimeout(t);
}, [pendingResetAll]);
const switchTo = useCallback( const switchTo = useCallback(
(sessionId: import("@shared/ids").SessionId) => { (sessionId: import("@shared/ids").SessionId) => {
navigateTo({ sessionId, mode: "review" }); navigateTo({ sessionId, mode: "review" });
@@ -77,7 +108,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
setError(null); setError(null);
try { try {
const created = service.create(newName); const trimmed = newName.trim();
const effective = trimmed.length === 0 ? service.nextDefaultName() : newName;
const created = service.create(effective);
setNewName(""); setNewName("");
setCreating(false); setCreating(false);
setOpen(false); setOpen(false);
@@ -111,6 +144,41 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
navigateTo({ sessionId: null, mode: "review" }); navigateTo({ sessionId: null, mode: "review" });
}, [active, pendingDelete, service]); }, [active, pendingDelete, service]);
const handleRowDelete = useCallback(
(e: React.MouseEvent, id: SessionId) => {
e.stopPropagation();
if (pendingRowDelete !== id) {
setPendingRowDelete(id);
return;
}
const wasActive = active?.id === id;
service.delete(id);
setPendingRowDelete(null);
if (wasActive) {
setOpen(false);
navigateTo({ sessionId: null, mode: "review" });
}
},
[active, pendingRowDelete, service],
);
const handleResetAll = useCallback(() => {
if (!pendingResetAll) {
setPendingResetAll(true);
return;
}
clearAllSessionData();
setPendingResetAll(false);
setOpen(false);
// Force a full reload so every in-memory cache (sessions repo,
// byte stores, engine snapshots) starts fresh from the cleared
// localStorage.
if (typeof window !== "undefined") {
window.location.hash = "";
window.location.reload();
}
}, [pendingResetAll]);
return ( return (
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu"> <div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
<button <button
@@ -155,22 +223,48 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}> <div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
Switch to Switch to
</div> </div>
{sessions.map((s) => ( {sessions.map((s) => {
<button const rowArmed = pendingRowDelete === s.id;
return (
<div
key={s.id} key={s.id}
style={{
display: "flex",
alignItems: "center",
background: active?.id === s.id ? "#e8f0ff" : "transparent",
}}
>
<button
type="button" type="button"
role="menuitem" role="menuitem"
data-testid={`session-switch-${s.id}`} data-testid={`session-switch-${s.id}`}
onClick={() => switchTo(s.id)} onClick={() => switchTo(s.id)}
style={{ style={{ ...menuItemStyle, flex: 1 }}
...menuItemStyle,
background: active?.id === s.id ? "#e8f0ff" : "transparent",
}}
> >
{s.name} {s.name}
{active?.id === s.id ? " · open" : ""} {active?.id === s.id ? " · open" : ""}
</button> </button>
))} <button
type="button"
aria-label={`Delete session ${s.name}`}
data-testid={`session-row-delete-${s.id}`}
onClick={(e) => handleRowDelete(e, s.id)}
title={rowArmed ? "Click again to confirm" : "Delete session and drop all its data"}
style={{
background: rowArmed ? "#ffe5e5" : "transparent",
border: "none",
color: "#7a0000",
cursor: "pointer",
padding: "4px 8px",
fontSize: 14,
lineHeight: 1,
}}
>
{rowArmed ? "Confirm?" : "✕"}
</button>
</div>
);
})}
<hr style={dividerStyle} /> <hr style={dividerStyle} />
</> </>
)} )}
@@ -316,6 +410,66 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
</button> </button>
)} )}
<hr style={dividerStyle} />
<div
style={{
padding: "4px 8px",
color: "#666",
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.5,
}}
>
PDF diagnostics
</div>
<DebugCheckbox
label="Highlight text layer"
testid="session-menu-debug-textlayer"
title="Paint the PDF text-layer spans in light grey so you can see what's selectable. Logs every selection event to the browser console."
checked={debugTextLayer}
onChange={setDebugTextLayer}
/>
<DebugCheckbox
label="Hide canvas layer"
testid="session-menu-hide-canvas"
title="Hide the rendered glyphs so only the text/annotation overlay layers remain. Use to see if the textLayer covers regions where the canvas has no content."
checked={hideCanvas}
onChange={setHideCanvas}
/>
<DebugCheckbox
label="Hide text layer"
testid="session-menu-hide-textlayer"
title="Hide the invisible selection text layer entirely. Use to see if it's covering the canvas content underneath."
checked={hideTextLayer}
onChange={setHideTextLayer}
/>
<DebugCheckbox
label="Hide annotation layer"
testid="session-menu-hide-annotation"
title="Hide PDF annotations (stamps, form widgets, links). Use to see if a stamp is obscuring content or capturing your clicks."
checked={hideAnnotationLayer}
onChange={setHideAnnotationLayer}
/>
<DebugCheckbox
label="Hide XFA layer"
testid="session-menu-hide-xfa"
title="Hide the XFA form layer (rare; only present on Adobe XFA forms)."
checked={hideXfaLayer}
onChange={setHideXfaLayer}
/>
<button
type="button"
role="menuitem"
data-testid="session-menu-reset-all"
onClick={handleResetAll}
title="Wipe every session, every uploaded PDF, every annotation — and reload."
style={{ ...menuItemStyle, color: "#7a0000" }}
>
{pendingResetAll
? "Confirm — wipe everything?"
: "Reset all data…"}
</button>
{error && ( {error && (
<div <div
data-testid="session-menu-error" data-testid="session-menu-error"
@@ -336,6 +490,38 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
); );
} }
interface DebugCheckboxProps {
readonly label: string;
readonly testid: string;
readonly title: string;
readonly checked: boolean;
onChange(next: boolean): void;
}
function DebugCheckbox(p: DebugCheckboxProps) {
return (
<label
data-testid={p.testid}
title={p.title}
style={{
...menuItemStyle,
display: "flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={p.checked}
onChange={(e) => p.onChange(e.target.checked)}
style={{ margin: 0 }}
/>
{p.label}
</label>
);
}
const menuItemStyle: CSSProperties = { const menuItemStyle: CSSProperties = {
display: "block", display: "block",
width: "100%", width: "100%",

View File

@@ -20,6 +20,8 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import type { EvidenceLink } from "@shared/evidence-link";
import type { EventBus } from "@engine/events"; import type { EventBus } from "@engine/events";
import { import {
@@ -68,9 +70,20 @@ export interface BinderProviderProps {
* because its observers attach to the current `window`. * because its observers attach to the current `window`.
*/ */
readonly services?: Omit<BinderServices, "rect">; readonly services?: Omit<BinderServices, "rect">;
/**
* Restored evidence links for this session. Seeded directly into the
* repo (no bus events) so reload does not spuriously re-emit
* `EvidenceLinkCreated`.
*/
readonly initialLinks?: readonly EvidenceLink[];
} }
export function BinderProvider({ children, bus, services }: BinderProviderProps) { export function BinderProvider({
children,
bus,
services,
initialLinks,
}: BinderProviderProps) {
const built = useMemo<BinderServices>(() => { const built = useMemo<BinderServices>(() => {
const links = services?.links ?? createInMemoryLinkRepo(); const links = services?.links ?? createInMemoryLinkRepo();
const bindings = services?.bindings ?? createBindingService(links, bus); const bindings = services?.bindings ?? createBindingService(links, bus);
@@ -78,6 +91,15 @@ export function BinderProvider({ children, bus, services }: BinderProviderProps)
return { links, bindings, rect }; return { links, bindings, rect };
}, [bus, services]); }, [bus, services]);
useEffect(() => {
if (!initialLinks?.length || services?.links) return;
for (const link of initialLinks) {
if (!built.links.get(link.id)) {
built.links.create(link);
}
}
}, [built.links, initialLinks, services?.links]);
// Disconnect rect observers + listeners on unmount. // Disconnect rect observers + listeners on unmount.
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@@ -0,0 +1,117 @@
/**
* Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11).
* Styled to match EvidenceFormBody / InlineCaptureForm.
*/
import type { CSSProperties, ReactNode } from "react";
import type { FormFieldSchema } from "./FormRenderer";
export type FieldType = FormFieldSchema["type"];
const FIELD_TYPES: readonly { value: FieldType; label: string }[] = [
{ value: "text", label: "Text" },
{ value: "textarea", label: "Text area" },
{ value: "date", label: "Date" },
];
export interface FieldDefinitionFormProps {
readonly label: string;
readonly type: FieldType;
onChangeLabel(next: string): void;
onChangeType(next: FieldType): void;
onSave(): void;
onCancel(): void;
readonly saveLabel?: string;
readonly cancelLabel?: string;
readonly badge?: ReactNode;
readonly testidPrefix: string;
}
export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
const saveLabel = p.saveLabel ?? "Save";
const cancelLabel = p.cancelLabel ?? "Cancel";
return (
<div
data-testid={`${p.testidPrefix}-form`}
style={{
border: "1px dashed #b78b1c",
background: "#fff8d6",
marginBottom: 8,
borderRadius: 2,
padding: 8,
fontSize: 12,
}}
>
{p.badge && (
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
)}
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
Field label
</label>
<input
id={`${p.testidPrefix}-label`}
type="text"
value={p.label}
onChange={(e) => p.onChangeLabel(e.target.value)}
data-testid={`${p.testidPrefix}-label-input`}
style={inputStyle}
/>
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
Field type
</label>
<select
id={`${p.testidPrefix}-type`}
value={p.type}
onChange={(e) => p.onChangeType(e.target.value as FieldType)}
data-testid={`${p.testidPrefix}-type-select`}
style={inputStyle}
>
{FIELD_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<button
type="button"
onClick={p.onSave}
data-testid={`${p.testidPrefix}-save`}
style={buttonStyle}
>
{saveLabel}
</button>
<button
type="button"
onClick={p.onCancel}
data-testid={`${p.testidPrefix}-cancel`}
style={buttonStyle}
>
{cancelLabel}
</button>
</div>
</div>
);
}
const labelStyle: CSSProperties = {
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
};
const inputStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
};
const buttonStyle: CSSProperties = {
fontSize: 12,
padding: "4px 10px",
};

View File

@@ -6,18 +6,14 @@
* draw curves from the active field to its linked evidence card and on * draw curves from the active field to its linked evidence card and on
* to the source highlight. * to the source highlight.
* *
* Lives in `src/binder/` (not `src/work/`) because it depends on the * CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
* rect-registry hooks in `binder/visual-guide`. See `wiki/DependencyMap.md`
* §2/§5 for the `work ⊄ binder` rule that motivates this placement.
*
* No styling beyond minimum legibility (workplan T06 note). Tailwind /
* design system can land later without changing the registry contract.
*/ */
import { useRef, type ChangeEvent } from "react"; import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
import type { EvidenceTarget } from "@shared/evidence-link"; import type { EvidenceTarget } from "@shared/evidence-link";
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
import { useActiveState, type ActiveState } from "./state/active"; import { useActiveState, type ActiveState } from "./state/active";
import { useRegisterRect } from "./visual-guide/react-hooks"; import { useRegisterRect } from "./visual-guide/react-hooks";
@@ -40,35 +36,91 @@ export interface FormSchema {
readonly fields: readonly FormFieldSchema[]; readonly fields: readonly FormFieldSchema[];
} }
export interface FieldDefinitionPatch {
readonly label: string;
readonly type: FieldType;
}
export interface FormRendererProps { export interface FormRendererProps {
readonly schema: FormSchema; readonly schema: FormSchema;
readonly values?: Readonly<Record<string, string>>; readonly values?: Readonly<Record<string, string>>;
readonly onValueChange?: (fieldId: string, value: string) => void; readonly onValueChange?: (fieldId: string, value: string) => void;
/**
* Per-field annotation count. Rendered as a small chip beside the
* label so the user can tell which fields already have evidence.
*/
readonly linkCounts?: Readonly<Record<string, number>>; readonly linkCounts?: Readonly<Record<string, number>>;
readonly linkHints?: Readonly<Record<string, string>>;
readonly showAddFieldForm?: boolean;
readonly onRequestAddField?: () => void;
readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void;
readonly onCancelAddField?: () => void;
readonly editingFieldId?: string | null;
readonly onBeginEditField?: (fieldId: string) => void;
readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void;
readonly onCancelFieldEdit?: () => void;
} }
const iconButtonStyle: CSSProperties = {
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
lineHeight: 1,
};
function FieldRow({ function FieldRow({
field, field,
value, value,
linkCount, linkCount,
linkHint,
isActive, isActive,
isEditing,
editLabel,
editType,
onChange, onChange,
onFocus, onFocus,
onBeginEdit,
onChangeEditLabel,
onChangeEditType,
onSaveEdit,
onCancelEdit,
}: { }: {
field: FormFieldSchema; field: FormFieldSchema;
value: string; value: string;
linkCount: number; linkCount: number;
linkHint?: string;
isActive: boolean; isActive: boolean;
isEditing: boolean;
editLabel: string;
editType: FieldType;
onChange: (next: string) => void; onChange: (next: string) => void;
onFocus: () => void; onFocus: () => void;
onBeginEdit: () => void;
onChangeEditLabel: (next: string) => void;
onChangeEditType: (next: FieldType) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useRegisterRect("field", field.id, ref); useRegisterRect("field", field.id, ref);
if (isEditing) {
return (
<div ref={ref} data-field-id={field.id} style={{ marginBottom: 12 }}>
<FieldDefinitionForm
label={editLabel}
type={editType}
onChangeLabel={onChangeEditLabel}
onChangeType={onChangeEditType}
onSave={onSaveEdit}
onCancel={onCancelEdit}
saveLabel="Save field"
badge="Editing field"
testidPrefix={`field-edit-${field.id}`}
/>
</div>
);
}
const sharedProps = { const sharedProps = {
id: `field-${field.id}`, id: `field-${field.id}`,
value, value,
@@ -85,6 +137,7 @@ function FieldRow({
data-link-count={String(linkCount)} data-link-count={String(linkCount)}
aria-current={isActive ? "true" : undefined} aria-current={isActive ? "true" : undefined}
style={{ style={{
position: "relative",
marginBottom: 12, marginBottom: 12,
fontFamily: "system-ui, sans-serif", fontFamily: "system-ui, sans-serif",
padding: 4, padding: 4,
@@ -92,14 +145,40 @@ function FieldRow({
background: isActive ? "#e8f0ff" : "transparent", background: isActive ? "#e8f0ff" : "transparent",
}} }}
> >
<button
type="button"
aria-label={`Edit field ${field.label}`}
data-testid={`field-edit-toggle-${field.id}`}
title="Edit field label and type"
onClick={(e) => {
e.stopPropagation();
onBeginEdit();
}}
style={{
...iconButtonStyle,
position: "absolute",
top: 4,
right: 4,
zIndex: 1,
}}
>
</button>
<label <label
htmlFor={sharedProps.id} htmlFor={sharedProps.id}
style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 4 }} style={{
display: "block",
fontSize: 12,
fontWeight: 600,
marginBottom: 4,
paddingRight: 28,
}}
> >
{field.label} {field.label}
{linkCount > 0 ? ( {linkCount > 0 ? (
<span <span
data-testid={`field-${field.id}-chip`} data-testid={`field-${field.id}-chip`}
title={linkHint}
style={{ style={{
marginLeft: 8, marginLeft: 8,
padding: "1px 6px", padding: "1px 6px",
@@ -128,32 +207,116 @@ export function FormRenderer({
values, values,
onValueChange, onValueChange,
linkCounts, linkCounts,
linkHints,
showAddFieldForm,
onRequestAddField,
onConfirmAddField,
onCancelAddField,
editingFieldId,
onBeginEditField,
onSaveFieldEdit,
onCancelFieldEdit,
}: FormRendererProps) { }: FormRendererProps) {
const { state, focusTarget } = useActiveState(); const { state, focusTarget } = useActiveState();
const [addLabel, setAddLabel] = useState("New field");
const [addType, setAddType] = useState<FieldType>("text");
const [editLabel, setEditLabel] = useState("");
const [editType, setEditType] = useState<FieldType>("text");
const handleFocus = (fieldId: string) => { const handleFocus = (fieldId: string) => {
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId }; const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
focusTarget(target); focusTarget(target);
}; };
const beginEdit = (field: FormFieldSchema) => {
setEditLabel(field.label);
setEditType(field.type);
onBeginEditField?.(field.id);
};
return ( return (
<form <form
data-form-id={schema.id} data-form-id={schema.id}
style={{ padding: 12 }} style={{ padding: 12 }}
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
> >
<h2 style={{ fontSize: 14, marginTop: 0, fontFamily: "system-ui, sans-serif" }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 8,
}}
>
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
{schema.title} {schema.title}
</h2> </h2>
<button
type="button"
data-testid="add-field-button"
onClick={() => {
setAddLabel(`New field ${schema.fields.length + 1}`);
setAddType("text");
onRequestAddField?.();
}}
style={{
fontSize: 11,
padding: "4px 10px",
border: "1px solid #888",
borderRadius: 4,
background: "white",
cursor: "pointer",
}}
>
Add field
</button>
</div>
{showAddFieldForm && (
<FieldDefinitionForm
label={addLabel}
type={addType}
onChangeLabel={setAddLabel}
onChangeType={setAddType}
onSave={() =>
onConfirmAddField?.({
label: addLabel.trim(),
type: addType,
})
}
onCancel={() => onCancelAddField?.()}
saveLabel="Add field"
badge="New form field"
testidPrefix="field-add"
/>
)}
{schema.fields.map((field) => ( {schema.fields.map((field) => (
<FieldRow <FieldRow
key={field.id} key={field.id}
field={field} field={field}
value={values?.[field.id] ?? ""} value={values?.[field.id] ?? ""}
linkCount={linkCounts?.[field.id] ?? 0} linkCount={linkCounts?.[field.id] ?? 0}
{...(linkHints?.[field.id] != null
? { linkHint: linkHints[field.id] }
: {})}
isActive={isFieldActive(state, field.id)} isActive={isFieldActive(state, field.id)}
isEditing={editingFieldId === field.id}
editLabel={editLabel}
editType={editType}
onChange={(next) => onValueChange?.(field.id, next)} onChange={(next) => onValueChange?.(field.id, next)}
onFocus={() => handleFocus(field.id)} onFocus={() => handleFocus(field.id)}
onBeginEdit={() => beginEdit(field)}
onChangeEditLabel={setEditLabel}
onChangeEditType={setEditType}
onSaveEdit={() =>
onSaveFieldEdit?.(field.id, {
label: editLabel.trim(),
type: editType,
})
}
onCancelEdit={() => onCancelFieldEdit?.()}
/> />
))} ))}
</form> </form>

View File

@@ -76,7 +76,11 @@ export function createBindingService(
return stored; return stored;
}, },
unlinkEvidence(id) { unlinkEvidence(id) {
return links.delete(id); const removed = links.delete(id);
if (removed) {
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
}
return removed;
}, },
updateLink(id, input) { updateLink(id, input) {
const existing = links.get(id); const existing = links.get(id);

View File

@@ -54,6 +54,7 @@ type Action =
evidenceItemId: EvidenceItemId; evidenceItemId: EvidenceItemId;
annotationId: AnnotationId | null; annotationId: AnnotationId | null;
} }
| { type: "clear-active-evidence" }
| { type: "clear" }; | { type: "clear" };
function reducer(state: ActiveState, action: Action): ActiveState { function reducer(state: ActiveState, action: Action): ActiveState {
@@ -78,6 +79,12 @@ function reducer(state: ActiveState, action: Action): ActiveState {
activeEvidenceItemId: action.evidenceItemId, activeEvidenceItemId: action.evidenceItemId,
activeAnnotationId: action.annotationId, activeAnnotationId: action.annotationId,
}; };
case "clear-active-evidence":
return {
activeTarget: state.activeTarget,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
case "clear": case "clear":
return EMPTY_ACTIVE_STATE; return EMPTY_ACTIVE_STATE;
} }
@@ -90,6 +97,7 @@ export interface ActiveStateApi {
evidenceItemId: EvidenceItemId, evidenceItemId: EvidenceItemId,
annotationId?: AnnotationId | null, annotationId?: AnnotationId | null,
): void; ): void;
clearActiveEvidence(): void;
clear(): void; clear(): void;
} }
@@ -144,13 +152,17 @@ export function ActiveStateProvider(props: ActiveStateProviderProps) {
[props.bus], [props.bus],
); );
const clearActiveEvidence = useCallback(() => {
dispatch({ type: "clear-active-evidence" });
}, []);
const clear = useCallback(() => { const clear = useCallback(() => {
dispatch({ type: "clear" }); dispatch({ type: "clear" });
}, []); }, []);
const value = useMemo<ActiveStateApi>( const value = useMemo<ActiveStateApi>(
() => ({ state, focusTarget, setActiveEvidence, clear }), () => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
[state, focusTarget, setActiveEvidence, clear], [state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
); );
return createElement(ActiveStateContext.Provider, { value }, props.children); return createElement(ActiveStateContext.Provider, { value }, props.children);

View File

@@ -33,6 +33,14 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
} }
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.bottom };
}
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top };
}
/** /**
* Build a quadratic bezier from `a` to `b` whose control point bulges * Build a quadratic bezier from `a` to `b` whose control point bulges
* horizontally between them. The horizontal-bulge style is right for a * horizontally between them. The horizontal-bulge style is right for a
@@ -55,8 +63,8 @@ export interface OverlayProps {
} }
export function Overlay({ export function Overlay({
strokeColor = "#0050b3", strokeColor = "#999",
strokeWidth = 2, strokeWidth = 1,
className, className,
}: OverlayProps = {}) { }: OverlayProps = {}) {
const { state } = useActiveState(); const { state } = useActiveState();
@@ -72,10 +80,10 @@ export function Overlay({
: null; : null;
const out: string[] = []; const out: string[] = [];
if (fieldRect && cardRect) { if (fieldRect && cardRect) {
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect))); out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
} }
if (cardRect && highlightRect) { if (cardRect && highlightRect) {
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect))); out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
} }
void version; // memo invalidator void version; // memo invalidator
return out; return out;

View File

@@ -1,7 +0,0 @@
# `src/engine/` — services, repositories, event bus
Future home: `citation-engine` (the services half).
Owns: repositories for `Document`/`Annotation`/`EvidenceItem`/`EvidenceLink`,
ID generation orchestration, the event bus, and pure orchestration services.
May import from: `shared/` only (`wiki/DependencyMap.md` §4).

View File

@@ -1,168 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import { createEngine, type Engine, type EngineEvent } from "./index";
function fakeDocAndRep(): { document: Document; representation: DocumentRepresentation } {
const docId = "doc_fake" as DocumentId;
const repId = "rep_fake" as RepresentationId;
return {
document: {
id: docId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: "h",
canonicalText: "The quick brown fox.",
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
describe("Engine integration", () => {
let engine: Engine;
let events: EngineEvent[];
beforeEach(() => {
engine = createEngine();
events = [];
engine.bus.onAny((e) => events.push(e));
});
it("documentService.register stores both and emits DocumentImported + DocumentRepresentationGenerated", () => {
const { document, representation } = fakeDocAndRep();
const result = engine.documents.register({ document, representation });
expect(result.document).toBe(document);
expect(result.representation).toBe(representation);
expect(engine.documents.get(document.id)).toBe(document);
expect(engine.documents.getRepresentation(representation.id)).toBe(representation);
expect(events.map((e) => e.type)).toEqual(["DocumentImported", "DocumentRepresentationGenerated"]);
});
it("annotationService.create stamps an ID + normalize version + timestamps, then emits AnnotationCreated", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const selectors: Selector[] = [{ type: "TextQuoteSelector", exact: "brown fox" }];
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors,
quote: "brown fox",
note: "a quick mark",
});
expect(ann.id).toMatch(/^ann_/);
expect(ann.normalizeVersion).toBeGreaterThan(0);
expect(ann.createdAt).toBe(ann.updatedAt);
expect(engine.annotations.get(ann.id)).toBe(ann);
const created = events.find((e) => e.type === "AnnotationCreated");
expect(created?.type).toBe("AnnotationCreated");
});
it("setResolutionStatus emits AnnotationResolved for resolved/ambiguous and AnnotationResolutionFailed for unresolved/stale", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
});
events.length = 0;
engine.annotations.setResolutionStatus(ann.id, "resolved", { confidence: 0.95 });
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved"]);
engine.annotations.setResolutionStatus(ann.id, "unresolved", { confidence: 0, reason: "no quote match" });
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved", "AnnotationResolutionFailed"]);
});
it("evidenceService.create requires at least one annotation and emits EvidenceItemCreated", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
});
expect(() => engine.evidence.create({ annotationIds: [] })).toThrow();
const item = engine.evidence.create({
annotationIds: [ann.id],
commentary: "good quote",
});
expect(item.status).toBe("candidate");
expect(item.annotationIds).toEqual([ann.id]);
expect(events.find((e) => e.type === "EvidenceItemCreated")).toBeDefined();
});
it("setStatus emits EvidenceItemUpdated only on real change and carries previousStatus", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
});
const item = engine.evidence.create({ annotationIds: [ann.id] });
events.length = 0;
const same = engine.evidence.setStatus(item.id, "candidate");
expect(same).toBe(item);
expect(events).toEqual([]);
engine.evidence.setStatus(item.id, "confirmed");
const updated = events.find((e) => e.type === "EvidenceItemUpdated");
expect(updated).toBeDefined();
if (updated?.type === "EvidenceItemUpdated") {
expect(updated.previousStatus).toBe("candidate");
}
});
it("listByDocument scopes evidence items to a single document via annotation lookup", () => {
const a = fakeDocAndRep();
engine.documents.register(a);
const annA = engine.annotations.create({
documentId: a.document.id,
representationId: a.representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
});
engine.evidence.create({ annotationIds: [annA.id], commentary: "a" });
// Second, distinct document.
const otherDocId = "doc_other" as DocumentId;
const otherRepId = "rep_other" as RepresentationId;
engine.documents.register({
document: { ...a.document, id: otherDocId },
representation: { ...a.representation, id: otherRepId, documentId: otherDocId },
});
const annB = engine.annotations.create({
documentId: otherDocId,
representationId: otherRepId,
selectors: [{ type: "TextQuoteSelector", exact: "z" }],
});
engine.evidence.create({ annotationIds: [annB.id], commentary: "b" });
expect(engine.evidence.listByDocument(a.document.id)).toHaveLength(1);
expect(engine.evidence.listByDocument(otherDocId)).toHaveLength(1);
});
it("activate emits EvidenceItemActivated without mutating the item", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
});
const item = engine.evidence.create({ annotationIds: [ann.id] });
events.length = 0;
engine.evidence.activate(item.id, "sidebar");
const activated = events.find((e) => e.type === "EvidenceItemActivated");
expect(activated).toBeDefined();
if (activated?.type === "EvidenceItemActivated") {
expect(activated.source).toBe("sidebar");
}
});
});

View File

@@ -1,64 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentId } from "@shared/ids";
import { createEventBus } from "./bus";
const docId = "doc_test" as DocumentId;
const minimalDoc = {
id: docId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
describe("EventBus", () => {
it("delivers typed events to the registered listener", () => {
const bus = createEventBus();
const spy = vi.fn();
bus.on("DocumentImported", spy);
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0]![0]).toMatchObject({ type: "DocumentImported", documentId: docId });
expect(result.listenerCount).toBe(1);
expect(result.errors).toEqual([]);
});
it("does not deliver an event to listeners of a different type", () => {
const bus = createEventBus();
const spy = vi.fn();
bus.on("AnnotationCreated", spy);
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(spy).not.toHaveBeenCalled();
});
it("delivers every event to onAny listeners", () => {
const bus = createEventBus();
const spy = vi.fn();
bus.onAny(spy);
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
bus.emit({ type: "EvidenceItemActivated", evidenceItemId: "ev_x" as never });
expect(spy).toHaveBeenCalledTimes(2);
});
it("returns an unsubscribe function from on()", () => {
const bus = createEventBus();
const spy = vi.fn();
const off = bus.on("DocumentImported", spy);
off();
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(spy).not.toHaveBeenCalled();
});
it("captures listener errors and still calls subsequent listeners", () => {
const bus = createEventBus();
const boom = new Error("listener exploded");
const a = vi.fn(() => { throw boom; });
const b = vi.fn();
bus.on("DocumentImported", a);
bus.on("DocumentImported", b);
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
expect(result.errors).toEqual([boom]);
expect(result.listenerCount).toBe(2);
});
});

View File

@@ -1,79 +0,0 @@
/**
* Synchronous in-process event bus.
*
* Listeners fire in registration order on the calling stack; `emit` returns
* after every listener has run. A listener throwing does not stop later
* listeners — its error surfaces through the returned `errors` array so
* callers can decide whether to log, rethrow, or ignore.
*
* MVP-sufficient. ADR-0005 (persistence) will decide whether to upgrade to
* an async/queued bus when storage becomes durable.
*/
import type { EngineEvent, EngineEventOf, EngineEventType } from "./types";
export type EngineEventListener<T extends EngineEventType = EngineEventType> = (
event: EngineEventOf<T>,
) => void;
export type AnyEngineEventListener = (event: EngineEvent) => void;
export interface EmitResult {
readonly listenerCount: number;
readonly errors: readonly unknown[];
}
export interface EventBus {
on<T extends EngineEventType>(type: T, listener: EngineEventListener<T>): () => void;
onAny(listener: AnyEngineEventListener): () => void;
emit<T extends EngineEventType>(event: EngineEventOf<T>): EmitResult;
}
export function createEventBus(): EventBus {
const typedListeners = new Map<EngineEventType, Set<EngineEventListener>>();
const anyListeners = new Set<AnyEngineEventListener>();
return {
on(type, listener) {
let set = typedListeners.get(type);
if (!set) {
set = new Set();
typedListeners.set(type, set);
}
set.add(listener as unknown as EngineEventListener);
return () => {
set!.delete(listener as unknown as EngineEventListener);
};
},
onAny(listener) {
anyListeners.add(listener);
return () => {
anyListeners.delete(listener);
};
},
emit(event) {
const errors: unknown[] = [];
let count = 0;
const typedSet = typedListeners.get(event.type);
if (typedSet) {
for (const l of typedSet) {
count++;
try {
(l as AnyEngineEventListener)(event);
} catch (err) {
errors.push(err);
}
}
}
for (const l of anyListeners) {
count++;
try {
l(event);
} catch (err) {
errors.push(err);
}
}
return { listenerCount: count, errors };
},
};
}

View File

@@ -1,8 +0,0 @@
export * from "./types";
export {
createEventBus,
type EventBus,
type EngineEventListener,
type AnyEngineEventListener,
type EmitResult,
} from "./bus";

View File

@@ -1,146 +0,0 @@
/**
* Engine event vocabulary.
*
* Implements `wiki/SharedContracts.md` §4 (closed event list). Each event
* carries the *minimum* identifying payload needed by downstream listeners;
* services hand back the full domain object to the caller separately.
*
* Adding an event requires updating SharedContracts.md first.
*/
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
import type {
EvidenceLink,
EvidenceTarget,
} from "@shared/evidence-link";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
EvidenceLinkId,
RepresentationId,
SessionId,
} from "@shared/ids";
import type { Session } from "@shared/session";
export interface DocumentImportedEvent {
readonly type: "DocumentImported";
readonly documentId: DocumentId;
readonly document: Document;
}
export interface DocumentRepresentationGeneratedEvent {
readonly type: "DocumentRepresentationGenerated";
readonly documentId: DocumentId;
readonly representationId: RepresentationId;
readonly representation: DocumentRepresentation;
}
export interface DocumentRemovedEvent {
readonly type: "DocumentRemoved";
readonly documentId: DocumentId;
}
export interface AnnotationCreatedEvent {
readonly type: "AnnotationCreated";
readonly annotationId: AnnotationId;
readonly annotation: Annotation;
}
export interface AnnotationResolvedEvent {
readonly type: "AnnotationResolved";
readonly annotationId: AnnotationId;
readonly status: AnnotationResolutionStatus;
readonly confidence: number;
}
export interface AnnotationResolutionFailedEvent {
readonly type: "AnnotationResolutionFailed";
readonly annotationId: AnnotationId;
readonly reason: string;
}
export interface EvidenceItemCreatedEvent {
readonly type: "EvidenceItemCreated";
readonly evidenceItemId: EvidenceItemId;
readonly evidenceItem: EvidenceItem;
}
export interface EvidenceItemUpdatedEvent {
readonly type: "EvidenceItemUpdated";
readonly evidenceItemId: EvidenceItemId;
readonly evidenceItem: EvidenceItem;
readonly previousStatus: EvidenceItemStatus;
}
export interface EvidenceItemActivatedEvent {
readonly type: "EvidenceItemActivated";
readonly evidenceItemId: EvidenceItemId;
readonly source?: "sidebar" | "form-field" | "citation-card";
}
export interface EvidenceLinkCreatedEvent {
readonly type: "EvidenceLinkCreated";
readonly linkId: EvidenceLinkId;
readonly link: EvidenceLink;
}
export interface EvidenceLinkUpdatedEvent {
readonly type: "EvidenceLinkUpdated";
readonly linkId: EvidenceLinkId;
readonly link: EvidenceLink;
}
export interface FormFieldActivatedEvent {
readonly type: "FormFieldActivated";
readonly target: EvidenceTarget;
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
| EvidenceItemCreatedEvent
| EvidenceItemUpdatedEvent
| EvidenceItemActivatedEvent
| EvidenceLinkCreatedEvent
| EvidenceLinkUpdatedEvent
| FormFieldActivatedEvent
| SessionCreatedEvent
| SessionRenamedEvent
| SessionDeletedEvent
| SessionActivatedEvent;
export type EngineEventType = EngineEvent["type"];
export type EngineEventOf<T extends EngineEventType> = Extract<EngineEvent, { type: T }>;

View File

@@ -1,61 +0,0 @@
/**
* Engine composition root.
*
* `createEngine()` wires in-memory repos to the services and shares a single
* event bus. The app layer holds the returned `Engine` instance and passes
* its services into the UI.
*
* Swapping the repository implementation later (ADR-0005) is a matter of
* replacing `createInMemoryRepos()` here. The service signatures don't
* change.
*/
import { createEventBus, type EventBus } from "./events";
import {
createInMemoryRepos,
type InMemoryRepos,
} from "./repos";
import {
createAnnotationService,
createDocumentService,
createEvidenceService,
type AnnotationService,
type DocumentService,
type EvidenceService,
} from "./services";
export * from "./events";
export * from "./repos";
export * from "./services";
export * from "./rendering";
export {
SNAPSHOT_VERSION,
attachPersister,
captureSnapshot,
documentIdsIn,
restoreFromStorage,
restoreSnapshot,
type EngineSnapshot,
type PersisterOptions,
} from "./persistence";
export interface Engine {
readonly bus: EventBus;
readonly repos: InMemoryRepos;
readonly documents: DocumentService;
readonly annotations: AnnotationService;
readonly evidence: EvidenceService;
}
export function createEngine(): Engine {
const bus = createEventBus();
const repos = createInMemoryRepos();
const documents = createDocumentService(repos.documents, repos.representations, bus);
const annotations = createAnnotationService(repos.annotations, bus);
const evidence = createEvidenceService(
repos.evidenceItems,
(id) => repos.annotations.get(id),
bus,
);
return { bus, repos, documents, annotations, evidence };
}

View File

@@ -1,183 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import {
attachPersister,
captureSnapshot,
createEngine,
restoreFromStorage,
restoreSnapshot,
type Engine,
type EngineEvent,
type EngineSnapshot,
} from "./index";
function fakeDocAndRep(suffix: string): {
document: Document;
representation: DocumentRepresentation;
} {
const docId = `doc_${suffix}` as DocumentId;
const repId = `rep_${suffix}` as RepresentationId;
return {
document: {
id: docId,
mediaType: "application/pdf",
title: `Doc ${suffix}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: `hash-${suffix}`,
canonicalText: "The quick brown fox.",
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
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 seed(engine: Engine, suffix: string) {
const { document, representation } = fakeDocAndRep(suffix);
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
quote: "brown fox",
});
const item = engine.evidence.create({
annotationIds: [ann.id],
commentary: `commentary-${suffix}`,
});
return { document, representation, ann, item };
}
describe("captureSnapshot + restoreSnapshot", () => {
it("round-trips documents, representations, annotations and evidence items", () => {
const src = createEngine();
const a = seed(src, "a");
const b = seed(src, "b");
const snap = captureSnapshot(src);
expect(snap.documents).toHaveLength(2);
expect(snap.representations).toHaveLength(2);
expect(snap.annotations).toHaveLength(2);
expect(snap.evidenceItems).toHaveLength(2);
const dst = createEngine();
restoreSnapshot(dst, snap);
expect(dst.documents.get(a.document.id)?.title).toBe("Doc a");
expect(dst.documents.get(b.document.id)?.title).toBe("Doc b");
expect(dst.annotations.get(a.ann.id)?.quote).toBe("brown fox");
expect(dst.evidence.get(a.item.id)?.commentary).toBe("commentary-a");
});
it("restoreSnapshot does NOT emit *Created events (events would loop the persister)", () => {
const src = createEngine();
seed(src, "x");
const snap = captureSnapshot(src);
const dst = createEngine();
const seen: EngineEvent["type"][] = [];
dst.bus.onAny((e) => seen.push(e.type));
restoreSnapshot(dst, snap);
expect(seen).toEqual([]);
});
it("rejects a snapshot with a mismatching version", () => {
const dst = createEngine();
expect(() =>
restoreSnapshot(dst, {
version: 999,
documents: [],
representations: [],
annotations: [],
evidenceItems: [],
} as EngineSnapshot),
).toThrow(/version/);
});
});
describe("attachPersister", () => {
let storage: ReturnType<typeof memoryStorage>;
let engine: Engine;
const KEY = "ce-test-snap";
beforeEach(() => {
storage = memoryStorage();
engine = createEngine();
});
it("writes a snapshot to storage on every mutating event", () => {
const off = attachPersister(engine, { key: KEY, storage });
expect(storage.getItem(KEY)).toBeNull();
seed(engine, "z");
const raw = storage.getItem(KEY);
expect(raw).not.toBeNull();
const snap = JSON.parse(raw!) as EngineSnapshot;
expect(snap.documents).toHaveLength(1);
expect(snap.evidenceItems).toHaveLength(1);
off();
});
it("stops writing after the unsubscribe is called", () => {
const off = attachPersister(engine, { key: KEY, storage });
seed(engine, "q");
const after = storage.getItem(KEY);
off();
seed(engine, "r");
expect(storage.getItem(KEY)).toBe(after);
});
it("survives a JSON.stringify failure without throwing into the caller", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failing = { ...memoryStorage(), setItem: () => { throw new Error("boom"); } };
attachPersister(engine, { key: KEY, storage: failing });
expect(() => seed(engine, "k")).not.toThrow();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
describe("restoreFromStorage", () => {
it("returns {restored: false} when the key is empty", () => {
const storage = memoryStorage();
const engine = createEngine();
const result = restoreFromStorage(engine, { key: "missing", storage });
expect(result.restored).toBe(false);
});
it("hydrates the engine when storage holds a valid snapshot", () => {
const src = createEngine();
seed(src, "rs");
const storage = memoryStorage();
storage.setItem("snap", JSON.stringify(captureSnapshot(src)));
const dst = createEngine();
const result = restoreFromStorage(dst, { key: "snap", storage });
expect(result.restored).toBe(true);
expect(dst.documents.list()).toHaveLength(1);
});
it("ignores malformed JSON without throwing", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const storage = memoryStorage();
storage.setItem("snap", "not-json");
const engine = createEngine();
const result = restoreFromStorage(engine, { key: "snap", storage });
expect(result.restored).toBe(false);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -1,138 +0,0 @@
/**
* Engine snapshot + restore.
*
* MVP "persistence" — capture the engine's in-memory state into a JSON blob
* and restore it later. Used by the SPA to survive page reloads via
* `localStorage` until ADR-0005 lands a real store.
*
* Restore deliberately bypasses the service layer: it writes directly to
* the repos so no `*Created` events fire. Without that, restoring would
* trigger the persister to re-write the same snapshot — and if the user
* has another tab open, it would also broadcast spurious "this annotation
* just appeared" events to UI listeners.
*/
import type { Annotation } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type { DocumentId } from "@shared/ids";
import type { Engine } from "./index";
export const SNAPSHOT_VERSION = 1;
export interface EngineSnapshot {
readonly version: number;
readonly documents: readonly Document[];
readonly representations: readonly DocumentRepresentation[];
readonly annotations: readonly Annotation[];
readonly evidenceItems: readonly EvidenceItem[];
}
export function captureSnapshot(engine: Engine): EngineSnapshot {
const documents = engine.documents.list();
// Gather representations per known document.
const representations: DocumentRepresentation[] = [];
const annotations: Annotation[] = [];
const evidenceItems: EvidenceItem[] = [];
const seenItemIds = new Set<string>();
for (const doc of documents) {
representations.push(...engine.documents.listRepresentations(doc.id));
annotations.push(...engine.annotations.listByDocument(doc.id));
for (const item of engine.evidence.listByDocument(doc.id)) {
// listByDocument keys off annotation lookup; an item that shares
// annotations across two documents would surface twice. De-dupe.
if (!seenItemIds.has(item.id)) {
seenItemIds.add(item.id);
evidenceItems.push(item);
}
}
}
return {
version: SNAPSHOT_VERSION,
documents: [...documents],
representations,
annotations,
evidenceItems,
};
}
export function restoreSnapshot(engine: Engine, snapshot: EngineSnapshot): void {
if (snapshot.version !== SNAPSHOT_VERSION) {
throw new Error(
`restoreSnapshot: snapshot version ${snapshot.version} does not match current ${SNAPSHOT_VERSION}`,
);
}
for (const d of snapshot.documents) engine.repos.documents.create(d);
for (const r of snapshot.representations) engine.repos.representations.create(r);
for (const a of snapshot.annotations) engine.repos.annotations.create(a);
for (const i of snapshot.evidenceItems) engine.repos.evidenceItems.create(i);
}
export interface PersisterOptions {
/** Storage key. */
readonly key: string;
/** Storage shim — defaults to globalThis.localStorage. */
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
/**
* Subscribe to engine events and write a fresh snapshot on every mutation.
* Returns the unsubscribe function.
*
* Initial snapshot is NOT written — call `captureSnapshot` + `storage.setItem`
* yourself if you want a baseline.
*/
export function attachPersister(engine: Engine, options: PersisterOptions): () => void {
const storage = options.storage ?? globalThis.localStorage;
const write = () => {
const snap = captureSnapshot(engine);
try {
storage.setItem(options.key, JSON.stringify(snap));
} catch (err) {
// localStorage quota / serialization errors shouldn't crash the app.
// Surface to the console; ADR-0005 owns the durable fix.
console.warn("attachPersister: write failed", err);
}
};
const offs = [
engine.bus.on("DocumentImported", write),
engine.bus.on("DocumentRepresentationGenerated", write),
engine.bus.on("AnnotationCreated", write),
engine.bus.on("AnnotationResolved", write),
engine.bus.on("AnnotationResolutionFailed", write),
engine.bus.on("EvidenceItemCreated", write),
engine.bus.on("EvidenceItemUpdated", write),
];
return () => {
for (const off of offs) off();
};
}
export type RestoreFromStorageOptions = PersisterOptions;
export function restoreFromStorage(
engine: Engine,
options: RestoreFromStorageOptions,
): { readonly restored: boolean; readonly snapshot?: EngineSnapshot } {
const storage = options.storage ?? globalThis.localStorage;
const raw = storage.getItem(options.key);
if (!raw) return { restored: false };
try {
const parsed = JSON.parse(raw) as EngineSnapshot;
if (typeof parsed !== "object" || parsed === null) return { restored: false };
restoreSnapshot(engine, parsed);
return { restored: true, snapshot: parsed };
} catch (err) {
console.warn("restoreFromStorage: parse failed, ignoring stored snapshot", err);
return { restored: false };
}
}
/**
* Narrow helper: get the set of document ids restored from a snapshot.
* Useful for the SPA's "show me what was open last time" logic.
*/
export function documentIdsIn(snapshot: EngineSnapshot): readonly DocumentId[] {
return snapshot.documents.map((d) => d.id);
}

View File

@@ -1,172 +0,0 @@
/**
* 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

@@ -1,92 +0,0 @@
/**
* 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

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

View File

@@ -1,194 +0,0 @@
/**
* 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

@@ -1,68 +0,0 @@
/**
* 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

@@ -1,47 +0,0 @@
/**
* 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

@@ -1,166 +0,0 @@
/**
* In-memory `Map`-backed repositories.
*
* Implements the MVP storage layer. The repository interfaces match the
* shape that ADR-0005's eventual persistence implementation will satisfy,
* so swapping `createInMemoryRepos()` for a SQLite/Postgres factory later
* is a localised change.
*
* All mutating methods return the *stored* object so callers can pick up
* server-assigned fields (none in MVP, but the contract anticipates it).
*/
import type { Annotation } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
export interface DocumentRepository {
create(document: Document): Document;
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 {
create(annotation: Annotation): Annotation;
get(id: AnnotationId): Annotation | null;
listByDocument(documentId: DocumentId): readonly Annotation[];
update(annotation: Annotation): Annotation;
}
export interface EvidenceItemRepository {
create(item: EvidenceItem): EvidenceItem;
get(id: EvidenceItemId): EvidenceItem | null;
listByDocument(
documentId: DocumentId,
annotationLookup: (id: AnnotationId) => Annotation | null,
): readonly EvidenceItem[];
update(item: EvidenceItem): EvidenceItem;
}
export interface InMemoryRepos {
readonly documents: DocumentRepository;
readonly representations: RepresentationRepository;
readonly annotations: AnnotationRepository;
readonly evidenceItems: EvidenceItemRepository;
}
export function createInMemoryRepos(): InMemoryRepos {
const documents = new Map<DocumentId, Document>();
const representations = new Map<RepresentationId, DocumentRepresentation>();
const annotations = new Map<AnnotationId, Annotation>();
const evidenceItems = new Map<EvidenceItemId, EvidenceItem>();
return {
documents: {
create(document) {
documents.set(document.id, document);
return document;
},
get(id) {
return documents.get(id) ?? null;
},
list() {
return [...documents.values()];
},
update(document) {
if (!documents.has(document.id)) {
throw new Error(`DocumentRepository.update: unknown id ${document.id}`);
}
documents.set(document.id, document);
return document;
},
delete(id) {
return documents.delete(id);
},
},
representations: {
create(representation) {
representations.set(representation.id, representation);
return representation;
},
get(id) {
return representations.get(id) ?? null;
},
listByDocument(documentId) {
const out: DocumentRepresentation[] = [];
for (const rep of representations.values()) {
if (rep.documentId === documentId) out.push(rep);
}
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) {
annotations.set(annotation.id, annotation);
return annotation;
},
get(id) {
return annotations.get(id) ?? null;
},
listByDocument(documentId) {
const out: Annotation[] = [];
for (const ann of annotations.values()) {
if (ann.documentId === documentId) out.push(ann);
}
return out;
},
update(annotation) {
if (!annotations.has(annotation.id)) {
throw new Error(`AnnotationRepository.update: unknown id ${annotation.id}`);
}
annotations.set(annotation.id, annotation);
return annotation;
},
},
evidenceItems: {
create(item) {
evidenceItems.set(item.id, item);
return item;
},
get(id) {
return evidenceItems.get(id) ?? null;
},
listByDocument(documentId, annotationLookup) {
const out: EvidenceItem[] = [];
for (const item of evidenceItems.values()) {
if (item.annotationIds.some((aid) => annotationLookup(aid)?.documentId === documentId)) {
out.push(item);
}
}
return out;
},
update(item) {
if (!evidenceItems.has(item.id)) {
throw new Error(`EvidenceItemRepository.update: unknown id ${item.id}`);
}
evidenceItems.set(item.id, item);
return item;
},
},
};
}

View File

@@ -1,12 +0,0 @@
export {
createInMemoryRepos,
type InMemoryRepos,
type DocumentRepository,
type RepresentationRepository,
type AnnotationRepository,
type EvidenceItemRepository,
} from "./in-memory";
export {
createInMemorySessionRepository,
type SessionRepository,
} from "./in-memory-sessions";

View File

@@ -1,102 +0,0 @@
/**
* Annotation service — creates technical marks on document ranges and
* emits `AnnotationCreated`. Resolution-status updates emit
* `AnnotationResolved` / `AnnotationResolutionFailed`.
*
* Annotation creation is the engine's response to a user action in the
* viewer (T07). The viewer adapter has already turned the selection into
* `Selector[]`; this service stamps an ID, normalize-version, timestamps,
* persists, and broadcasts.
*/
import type {
Annotation,
AnnotationResolutionStatus,
} from "@shared/annotation";
import type { DocumentId, RepresentationId, AnnotationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import { newId } from "@shared/ids";
import { NORMALIZE_VERSION } from "@shared/text/normalize";
import type { EventBus } from "../events";
import type { AnnotationRepository } from "../repos";
export interface CreateAnnotationInput {
readonly documentId: DocumentId;
readonly representationId?: RepresentationId;
readonly selectors: readonly Selector[];
readonly quote?: string;
readonly note?: string;
readonly createdBy?: string;
}
export interface AnnotationService {
create(input: CreateAnnotationInput): Annotation;
get(id: AnnotationId): Annotation | null;
listByDocument(documentId: DocumentId): readonly Annotation[];
setResolutionStatus(
id: AnnotationId,
status: AnnotationResolutionStatus,
opts: { readonly confidence: number; readonly reason?: string },
): Annotation;
}
export function createAnnotationService(
annotations: AnnotationRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): AnnotationService {
return {
create(input) {
const ts = now();
const annotation: Annotation = {
id: newId("annotation"),
documentId: input.documentId,
...(input.representationId !== undefined ? { representationId: input.representationId } : {}),
selectors: input.selectors,
...(input.quote !== undefined ? { quote: input.quote } : {}),
...(input.note !== undefined ? { note: input.note } : {}),
normalizeVersion: NORMALIZE_VERSION,
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = annotations.create(annotation);
bus.emit({ type: "AnnotationCreated", annotationId: stored.id, annotation: stored });
return stored;
},
get(id) {
return annotations.get(id);
},
listByDocument(documentId) {
return annotations.listByDocument(documentId);
},
setResolutionStatus(id, status, opts) {
const existing = annotations.get(id);
if (!existing) {
throw new Error(`AnnotationService.setResolutionStatus: unknown id ${id}`);
}
const updated: Annotation = {
...existing,
resolutionStatus: status,
updatedAt: now(),
};
const stored = annotations.update(updated);
if (status === "unresolved" || status === "stale") {
bus.emit({
type: "AnnotationResolutionFailed",
annotationId: stored.id,
reason: opts.reason ?? status,
});
} else {
bus.emit({
type: "AnnotationResolved",
annotationId: stored.id,
status,
confidence: opts.confidence,
});
}
return stored;
},
};
}

View File

@@ -1,74 +0,0 @@
/**
* Document service — registers ingested documents and emits the §4 events.
*
* The ingest pipeline (`src/source/pdf/ingest.ts`) is a pure function over
* bytes — it does not touch the engine. The app composition root calls
* `ingestPdf` then hands the result to `documentService.register()`, which
* is where the engine takes over: persist into the repos, emit
* `DocumentImported` + `DocumentRepresentationGenerated`.
*/
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { EventBus } from "../events";
import type { DocumentRepository, RepresentationRepository } from "../repos";
export interface DocumentService {
register(input: {
readonly document: Document;
readonly representation: DocumentRepresentation;
}): { readonly document: Document; readonly representation: DocumentRepresentation };
get(id: DocumentId): Document | null;
list(): readonly Document[];
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
remove(id: DocumentId): boolean;
}
export function createDocumentService(
documents: DocumentRepository,
representations: RepresentationRepository,
bus: EventBus,
): DocumentService {
return {
register({ document, representation }) {
const storedDocument = documents.create(document);
const storedRepresentation = representations.create(representation);
bus.emit({
type: "DocumentImported",
documentId: storedDocument.id,
document: storedDocument,
});
bus.emit({
type: "DocumentRepresentationGenerated",
documentId: storedDocument.id,
representationId: storedRepresentation.id,
representation: storedRepresentation,
});
return { document: storedDocument, representation: storedRepresentation };
},
get(id) {
return documents.get(id);
},
list() {
return documents.list();
},
getRepresentation(id) {
return representations.get(id);
},
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

@@ -1,127 +0,0 @@
/**
* Evidence service — creates EvidenceItems on top of annotations and
* tracks their lifecycle. Emits §4 events: `EvidenceItemCreated`,
* `EvidenceItemUpdated`, `EvidenceItemActivated`.
*
* MVP item shape per `wiki/SharedContracts.md` §2.2: status starts at
* `candidate`, may transition to `confirmed | rejected | needs-check`.
* Item-level relation/strength (supports/contradicts/...) lives on the
* link, not the item — that's CE-WP-0003.
*/
import type { Annotation } from "@shared/annotation";
import type {
EvidenceItem,
EvidenceItemStatus,
} from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
} from "@shared/ids";
import { newId } from "@shared/ids";
import type { EventBus, EvidenceItemActivatedEvent } from "../events";
import type { EvidenceItemRepository } from "../repos";
export interface CreateEvidenceItemInput {
readonly annotationIds: readonly AnnotationId[];
readonly title?: string;
readonly commentary?: string;
readonly status?: EvidenceItemStatus;
readonly confidence?: number;
readonly tags?: readonly string[];
readonly createdBy?: string;
}
export interface EvidenceService {
create(input: CreateEvidenceItemInput): EvidenceItem;
get(id: EvidenceItemId): EvidenceItem | null;
listByDocument(documentId: DocumentId): readonly EvidenceItem[];
setStatus(id: EvidenceItemId, status: EvidenceItemStatus): EvidenceItem;
updateCommentary(id: EvidenceItemId, commentary: string): EvidenceItem;
activate(
id: EvidenceItemId,
source?: EvidenceItemActivatedEvent["source"],
): EvidenceItem;
}
export function createEvidenceService(
items: EvidenceItemRepository,
annotationLookup: (id: AnnotationId) => Annotation | null,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): EvidenceService {
return {
create(input) {
if (input.annotationIds.length === 0) {
throw new Error("EvidenceService.create: at least one annotationId is required");
}
const ts = now();
const item: EvidenceItem = {
id: newId("evidence"),
annotationIds: input.annotationIds,
...(input.title !== undefined ? { title: input.title } : {}),
...(input.commentary !== undefined ? { commentary: input.commentary } : {}),
status: input.status ?? "candidate",
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = items.create(item);
bus.emit({ type: "EvidenceItemCreated", evidenceItemId: stored.id, evidenceItem: stored });
return stored;
},
get(id) {
return items.get(id);
},
listByDocument(documentId) {
return items.listByDocument(documentId, annotationLookup);
},
setStatus(id, status) {
const existing = items.get(id);
if (!existing) {
throw new Error(`EvidenceService.setStatus: unknown id ${id}`);
}
if (existing.status === status) return existing;
const updated: EvidenceItem = { ...existing, status, updatedAt: now() };
const stored = items.update(updated);
bus.emit({
type: "EvidenceItemUpdated",
evidenceItemId: stored.id,
evidenceItem: stored,
previousStatus: existing.status,
});
return stored;
},
updateCommentary(id, commentary) {
const existing = items.get(id);
if (!existing) {
throw new Error(`EvidenceService.updateCommentary: unknown id ${id}`);
}
const updated: EvidenceItem = { ...existing, commentary, updatedAt: now() };
const stored = items.update(updated);
bus.emit({
type: "EvidenceItemUpdated",
evidenceItemId: stored.id,
evidenceItem: stored,
previousStatus: existing.status,
});
return stored;
},
activate(id, source) {
const existing = items.get(id);
if (!existing) {
throw new Error(`EvidenceService.activate: unknown id ${id}`);
}
bus.emit({
type: "EvidenceItemActivated",
evidenceItemId: existing.id,
...(source !== undefined ? { source } : {}),
});
return existing;
},
};
}

View File

@@ -1,27 +0,0 @@
export {
createDocumentService,
type DocumentService,
} from "./documents";
export {
createAnnotationService,
type AnnotationService,
type CreateAnnotationInput,
} from "./annotations";
export {
createEvidenceService,
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

@@ -1,204 +0,0 @@
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

@@ -1,302 +0,0 @@
/**
* 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

@@ -1,98 +0,0 @@
/**
* 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

@@ -1,8 +0,0 @@
# `src/shared/` — vocabulary, types, pure helpers
Future home: `citation-engine` (the shared types and contracts half of it).
Owns: `Document`, `Selector`, `Annotation`, `EvidenceItem`, `EvidenceLink`,
`EvidenceSet`, state enums, branded IDs, canonical text normalization.
May import from: nothing internal. Leaf node of the dependency graph
(`wiki/DependencyMap.md` §4).

View File

@@ -1,45 +0,0 @@
/**
* The Annotation type.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.1 (resolutionStatus)
* and `wiki/ArchitectureOverview.md` §4.4. Annotations are the *technical*
* mark on a document range — meaning and commentary live on EvidenceItem.
*/
import type { AnnotationId, DocumentId, RepresentationId } from "./ids";
import type { Selector } from "./selector";
/** Closed enum per `wiki/SharedContracts.md` §2.1. */
export type AnnotationResolutionStatus =
| "resolved"
| "ambiguous"
| "unresolved"
| "stale";
export interface Annotation {
readonly id: AnnotationId;
readonly documentId: DocumentId;
readonly representationId?: RepresentationId;
/**
* All available selectors for this passage, in order of expected
* resolution confidence. Per the §3 redundancy rule, the system stores
* every selector kind it could derive at capture time.
*/
readonly selectors: readonly Selector[];
/** Verbatim canonical text at capture time. */
readonly quote?: string;
/** Short human note attached to the technical mark. */
readonly note?: string;
/**
* Version of `normalize()` that was active when these selectors were
* stored. Recorded so future normalization changes can be detected as a
* migration concern. See `src/shared/text/normalize.ts`.
*/
readonly normalizeVersion: number;
readonly resolutionStatus?: AnnotationResolutionStatus;
readonly createdBy?: string;
/** ISO-8601 timestamp. */
readonly createdAt: string;
/** ISO-8601 timestamp. */
readonly updatedAt: string;
}

View File

@@ -1,75 +0,0 @@
/**
* 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

@@ -1,26 +0,0 @@
/**
* 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

@@ -1,35 +0,0 @@
/**
* 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

@@ -1,91 +0,0 @@
/**
* Document and DocumentRepresentation types.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
* `wiki/ArchitectureOverview.md` §4.1, §4.2. Pure data — no behavior.
*/
import type { DocumentId, RepresentationId } from "./ids";
/**
* The kind of normalized view derived from a source document.
*
* MVP recognises only `pdf-text`; the other variants are reserved for the
* HTML/Markdown/OCR adapters that arrive after CE-WP-0002.
*/
export type RepresentationType =
| "pdf-text"
| "html-dom"
| "markdown-rendered"
| "plain-text"
| "ocr-text";
/**
* Page-level geometry. One entry per physical PDF page.
* Coordinates are PDF user-space points (1/72 inch).
*/
export interface PageInfo {
/** 1-indexed physical page number. */
readonly page: number;
/** Page width in user-space points. */
readonly width: number;
/** Page height in user-space points. */
readonly height: number;
}
export type PageMap = readonly PageInfo[];
/**
* Maps canonical-text offset ranges to physical pages.
*
* Entries are sorted by `globalStart`, are non-overlapping, and together
* cover `[0, canonicalText.length)`. `pageLength` equals
* `globalEnd - globalStart` and is also the length of the page-local text
* (used by `PdfPageTextSelector`).
*/
export interface PageOffsetRange {
readonly page: number;
/** Inclusive canonical-text offset where this page begins. */
readonly globalStart: number;
/** Exclusive canonical-text offset where this page ends. */
readonly globalEnd: number;
/** Length of the page's text in canonical-text characters. */
readonly pageLength: number;
}
export type OffsetMap = readonly PageOffsetRange[];
/**
* Reserved for `StructuralSelector` (heading/section/AST path).
* Not implementable in MVP — type is `never` to enforce that at compile time.
*/
export type StructureMap = never;
/** A source document known to the system. */
export interface Document {
readonly id: DocumentId;
readonly title?: string;
readonly uri?: string;
readonly mediaType: string;
readonly fingerprint?: string;
readonly version?: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}
/** A normalized, addressable view of a `Document`. */
export interface DocumentRepresentation {
readonly id: RepresentationId;
readonly documentId: DocumentId;
readonly representationType: RepresentationType;
/** Hash of the canonical text — stable identifier for the representation. */
readonly contentHash: string;
/** Canonical text after `normalize()` is applied. */
readonly canonicalText?: string;
readonly pageMap?: PageMap;
readonly structureMap?: StructureMap;
readonly offsetMap?: OffsetMap;
/** ISO-8601 timestamp. */
readonly generatedAt: string;
}

View File

@@ -1,62 +0,0 @@
/**
* Conformance test: the runtime enum lists in `evidence-link.ts` must
* match the lists in `wiki/SharedContracts.md` §2.4 and §2.5 exactly.
*
* If you intentionally change an enum, update both the doc and the
* runtime list together — this test will tell you which one you forgot.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
EVIDENCE_LINK_STATUS_VALUES,
EVIDENCE_RELATION_VALUES,
} from "./evidence-link";
const HERE = fileURLToPath(new URL(".", import.meta.url));
const CONTRACTS_PATH = resolve(HERE, "../../wiki/SharedContracts.md");
function extractFencedListAfterHeading(markdown: string, heading: string): string[] {
const headingIndex = markdown.indexOf(heading);
if (headingIndex === -1) {
throw new Error(`Could not find heading "${heading}" in SharedContracts.md`);
}
const after = markdown.slice(headingIndex + heading.length);
const fenceOpen = after.indexOf("```");
if (fenceOpen === -1) throw new Error(`No fenced block after "${heading}"`);
const bodyStart = after.indexOf("\n", fenceOpen) + 1;
const fenceClose = after.indexOf("```", bodyStart);
if (fenceClose === -1) throw new Error(`Unterminated fenced block after "${heading}"`);
const body = after.slice(bodyStart, fenceClose);
return body
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Strip trailing " — explanatory note" if present (none in §2.4/§2.5 today,
// but §2.1/§2.2 use that style — being defensive keeps the helper reusable).
.map((line) => line.split(/\s+[—-]\s+/)[0]!.trim());
}
describe("EvidenceLink enum conformance with SharedContracts.md", () => {
const markdown = readFileSync(CONTRACTS_PATH, "utf8");
it("§2.4 EvidenceLink.status matches EVIDENCE_LINK_STATUS_VALUES", () => {
const docValues = extractFencedListAfterHeading(
markdown,
"### 2.4 `EvidenceLink.status` (per target)",
);
expect(docValues).toEqual([...EVIDENCE_LINK_STATUS_VALUES]);
});
it("§2.5 EvidenceLink.relation matches EVIDENCE_RELATION_VALUES", () => {
const docValues = extractFencedListAfterHeading(
markdown,
"### 2.5 `EvidenceLink.relation`",
);
expect(docValues).toEqual([...EVIDENCE_RELATION_VALUES]);
});
});

View File

@@ -1,107 +0,0 @@
/**
* EvidenceLink + EvidenceTarget shapes.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.4
* (EvidenceLink.status) and §2.5 (EvidenceLink.relation), and
* `wiki/ArchitectureOverview.md` §4 (target-type catalogue).
*
* An EvidenceLink ties exactly one EvidenceItem to one structured target
* (e.g. a form field). Multiple links per item are allowed when the same
* evidence supports several targets. Multiple links per target are allowed
* when several pieces of evidence apply to the same field — the
* EvidenceSet captures that ordered group.
*/
import type { EvidenceItemId, EvidenceLinkId } from "./ids";
/**
* Closed enum per `wiki/SharedContracts.md` §2.4.
*
* `no-evidence` is a *derived* state — computed when a target has zero
* links — and is therefore NOT stored on a link itself. The stored values
* are the five members of `EvidenceLinkStoredStatus`.
*/
export type EvidenceLinkStatus =
| "no-evidence"
| "candidate"
| "confirmed"
| "conflicting"
| "insufficient"
| "verified";
/**
* The subset of `EvidenceLinkStatus` that may appear on a stored link
* record. `no-evidence` is excluded because it is derived from the
* absence of links on a target, not stored.
*/
export type EvidenceLinkStoredStatus = Exclude<EvidenceLinkStatus, "no-evidence">;
/** Closed enum per `wiki/SharedContracts.md` §2.5. */
export type EvidenceRelation =
| "supports"
| "contradicts"
| "explains"
| "qualifies"
| "source-for"
| "context-for";
/**
* Known target-type catalogue per `wiki/ArchitectureOverview.md` §4
* (`EvidenceTargetType`). The MVP only exercises `"form-field"`; the
* others are reserved so future workplans can extend without renaming.
*/
export type EvidenceTargetType =
| "form-field"
| "claim"
| "requirement"
| "decision"
| "document-section";
/**
* Generic shape of an evidence target. `targetId` is opaque to the engine
* — the host subsystem (form renderer, claims index, …) owns the
* namespace for its `targetType`.
*/
export interface EvidenceTarget {
readonly targetType: EvidenceTargetType;
readonly targetId: string;
}
export interface EvidenceLink {
readonly id: EvidenceLinkId;
readonly evidenceItemId: EvidenceItemId;
readonly targetType: EvidenceTargetType;
readonly targetId: string;
readonly relation: EvidenceRelation;
readonly status: EvidenceLinkStoredStatus;
/** Optional 0..1 confidence assigned by user or auto-process. */
readonly confidence?: number;
readonly createdBy?: string;
/** ISO-8601 timestamp. */
readonly createdAt: string;
/** ISO-8601 timestamp. */
readonly updatedAt: string;
}
/**
* The canonical lists, exported for use by enum-conformance tests
* (see `evidence-link.test.ts`) and for any UI code that needs to
* enumerate options. Order matches `wiki/SharedContracts.md`.
*/
export const EVIDENCE_LINK_STATUS_VALUES: readonly EvidenceLinkStatus[] = [
"no-evidence",
"candidate",
"confirmed",
"conflicting",
"insufficient",
"verified",
];
export const EVIDENCE_RELATION_VALUES: readonly EvidenceRelation[] = [
"supports",
"contradicts",
"explains",
"qualifies",
"source-for",
"context-for",
];

View File

@@ -1,36 +0,0 @@
/**
* EvidenceSet — an ordered group of evidence items pointed at a target.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
* `wiki/ArchitectureOverview.md` §4.6.
*
* The set itself is target-shaped: it carries the `(targetType, targetId)`
* pair so the binder can answer "give me the EvidenceSet for this form
* field" in one call. `activeEvidenceItemId` is the membership of the
* set that the UI is currently focused on; cycling Tab/Shift-Tab through
* the field's chips updates it.
*/
import type { EvidenceItemId, EvidenceSetId } from "./ids";
import type { EvidenceTargetType } from "./evidence-link";
export interface EvidenceSet {
readonly id: EvidenceSetId;
readonly label?: string;
/**
* Optional target binding. Form-field sets always carry these; ad-hoc
* topical sets may leave them undefined.
*/
readonly targetType?: EvidenceTargetType;
readonly targetId?: string;
/**
* Membership in display order. The binder is free to reorder, but
* persistence preserves this order so cycling is deterministic.
*/
readonly evidenceItemIds: readonly EvidenceItemId[];
/**
* The currently active member, or undefined if the set is empty or
* no member is yet focused.
*/
readonly activeEvidenceItemId?: EvidenceItemId;
}

View File

@@ -1,37 +0,0 @@
/**
* EvidenceItem type.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.2 (status enum)
* and `wiki/ArchitectureOverview.md` §4.5. An EvidenceItem is the *meaning*
* layer on top of one or more technical Annotations.
*/
import type { AnnotationId, EvidenceItemId } from "./ids";
/** Closed enum per `wiki/SharedContracts.md` §2.2. */
export type EvidenceItemStatus =
| "candidate"
| "confirmed"
| "rejected"
| "needs-check";
export interface EvidenceItem {
readonly id: EvidenceItemId;
/**
* One or more annotations that together constitute the evidence.
* Multiple annotations are used when a piece of evidence spans
* discontiguous passages.
*/
readonly annotationIds: readonly AnnotationId[];
readonly title?: string;
readonly commentary?: string;
readonly status: EvidenceItemStatus;
/** Optional 0..1 confidence assigned by user or auto-process. */
readonly confidence?: number;
readonly tags?: readonly string[];
readonly createdBy?: string;
/** ISO-8601 timestamp. */
readonly createdAt: string;
/** ISO-8601 timestamp. */
readonly updatedAt: string;
}

View File

@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest";
import { newId } from "./ids";
describe("newId", () => {
it("returns ids with the expected prefix for each kind", () => {
expect(newId("document")).toMatch(/^doc_[0-9a-f-]{36}$/);
expect(newId("representation")).toMatch(/^rep_[0-9a-f-]{36}$/);
expect(newId("annotation")).toMatch(/^ann_[0-9a-f-]{36}$/);
expect(newId("evidence")).toMatch(/^ev_[0-9a-f-]{36}$/);
expect(newId("evidence-set")).toMatch(/^evset_[0-9a-f-]{36}$/);
expect(newId("evidence-link")).toMatch(/^evlink_[0-9a-f-]{36}$/);
expect(newId("citation-card")).toMatch(/^card_[0-9a-f-]{36}$/);
expect(newId("citation-recovery")).toMatch(/^crec_[0-9a-f-]{36}$/);
});
it("returns a unique id on every call", () => {
const a = newId("annotation");
const b = newId("annotation");
expect(a).not.toBe(b);
});
});

View File

@@ -1,58 +0,0 @@
/**
* Branded ID types and the `newId(kind)` factory.
*
* Implements the identifier portion of `wiki/SharedContracts.md` §1 and
* `wiki/ArchitectureOverview.md` §3.2. Each branded type is structurally a
* `string` but nominally distinct, so passing an `AnnotationId` where a
* `DocumentId` is required is a compile-time error.
*/
declare const __brand: unique symbol;
type Brand<K, T extends string> = K & { readonly [__brand]: T };
export type DocumentId = Brand<string, "DocumentId">;
export type RepresentationId = Brand<string, "RepresentationId">;
export type AnnotationId = Brand<string, "AnnotationId">;
export type EvidenceItemId = Brand<string, "EvidenceItemId">;
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;
representation: RepresentationId;
annotation: AnnotationId;
evidence: EvidenceItemId;
"evidence-set": EvidenceSetId;
"evidence-link": EvidenceLinkId;
"citation-card": CitationCardId;
"citation-recovery": CitationRecoveryAttemptId;
session: SessionId;
};
export type IdKind = keyof IdKindMap;
const PREFIXES: Record<IdKind, string> = {
document: "doc",
representation: "rep",
annotation: "ann",
evidence: "ev",
"evidence-set": "evset",
"evidence-link": "evlink",
"citation-card": "card",
"citation-recovery": "crec",
session: "sess",
};
/**
* Mint a new branded identifier of the requested kind.
*
* IDs use the shape `<prefix>_<uuid>` so they are human-recognizable when
* they show up in logs, URLs, or stored JSON.
*/
export function newId<K extends IdKind>(kind: K): IdKindMap[K] {
return `${PREFIXES[kind]}_${crypto.randomUUID()}` as IdKindMap[K];
}

View File

@@ -1,13 +0,0 @@
export * from "./ids";
export * from "./document";
export * from "./selector";
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

@@ -1,32 +0,0 @@
/**
* 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

@@ -1,36 +0,0 @@
/**
* 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

@@ -1,79 +0,0 @@
/**
* The Selector discriminated union.
*
* Implements `wiki/SharedContracts.md` §3. Each selector kind has a unique
* `type` discriminator and locates a passage inside one
* `DocumentRepresentation`.
*
* The MVP implements the four PDF-relevant variants
* (`TextQuoteSelector`, `TextPositionSelector`, `PdfRectSelector`,
* `PdfPageTextSelector`). The other three kinds (DOM, structural, fragment)
* are reserved as `never`-typed stubs so adding them later is a localised
* change.
*/
/** Exact quote with optional surrounding context (W3C-aligned). */
export interface TextQuoteSelector {
readonly type: "TextQuoteSelector";
/** The verbatim quoted passage from the canonical text. */
readonly exact: string;
/** Up to ~32 chars of canonical text immediately before `exact`. */
readonly prefix?: string;
/** Up to ~32 chars of canonical text immediately after `exact`. */
readonly suffix?: string;
}
/** Canonical-text character offsets (inclusive start, exclusive end). */
export interface TextPositionSelector {
readonly type: "TextPositionSelector";
readonly start: number;
readonly end: number;
}
/** A rectangle on a PDF page, in page-relative normalized coordinates (0..1). */
export interface NormalizedRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/** One or more rectangles on a single PDF page. */
export interface PdfRectSelector {
readonly type: "PdfRectSelector";
/** 1-indexed physical page number. */
readonly page: number;
readonly rects: readonly NormalizedRect[];
}
/** Page-local text offsets, for a single PDF page. */
export interface PdfPageTextSelector {
readonly type: "PdfPageTextSelector";
readonly page: number;
readonly start: number;
readonly end: number;
}
/** Reserved for HTML/Markdown viewer adapters. Not implementable in MVP. */
export type DomRangeSelector = never;
/** Reserved for heading/section/AST-path locators. Not implementable in MVP. */
export type StructuralSelector = never;
/** Reserved for exported deep-link fragments. Not implementable in MVP. */
export type FragmentSelector = never;
/**
* The closed union of all selector kinds. The `never` members keep the union
* exhaustive so future selector additions are a single edit.
*/
export type Selector =
| TextQuoteSelector
| TextPositionSelector
| PdfRectSelector
| PdfPageTextSelector
| DomRangeSelector
| StructuralSelector
| FragmentSelector;
export type SelectorType = Selector["type"];

View File

@@ -1,88 +0,0 @@
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

@@ -1,150 +0,0 @@
/**
* 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,
};
}

View File

@@ -1,26 +0,0 @@
/**
* `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

@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { NORMALIZE_VERSION, normalize } from "./normalize.js";
describe("normalize (NORMALIZE_VERSION=1)", () => {
it("returns the version constant alongside the text", () => {
const out = normalize("hello");
expect(out.version).toBe(NORMALIZE_VERSION);
expect(out.text).toBe("hello");
});
it("applies Unicode NFC composition", () => {
// "é" decomposed (e + combining acute) vs precomposed.
const decomposed = "café";
const precomposed = "café";
expect(normalize(decomposed).text).toBe(precomposed);
});
it("normalizes CRLF and CR line endings to LF", () => {
expect(normalize("a\r\nb\rc").text).toBe("a\nb\nc");
});
it("collapses horizontal whitespace runs to a single space", () => {
expect(normalize("a b\t\tc d").text).toBe("a b c d");
});
it("preserves paragraph boundaries but collapses 3+ blank lines to one", () => {
const input = "para one\n\n\n\npara two\n\npara three";
expect(normalize(input).text).toBe("para one\n\npara two\n\npara three");
});
it("strips soft hyphens (German line-broken word reassembly)", () => {
// German "Donau­dampf­schiff" line-broken with soft hyphens.
expect(normalize("Donau­dampf­schiff").text).toBe(
"Donaudampfschiff",
);
});
it("strips soft hyphens that span a newline ('word-\\nfragment' → 'wordfragment')", () => {
expect(normalize("word­\nfragment").text).toBe("wordfragment");
});
it("does not mangle ligatures (preserves the round-trip)", () => {
// The ligature "fi" (U+FB01) is left as-is — NFC does NOT decompose it.
// Test documents that current behavior so a future change is intentional.
expect(normalize("efficient").text).toBe("efficient");
});
it("handles a mixed-whitespace paragraph realistically", () => {
const input = " First line \r\n Second line.\r\n\r\n\r\nNext para. ";
expect(normalize(input).text).toBe("First line\nSecond line.\n\nNext para.");
});
it("returns an empty string for whitespace-only input", () => {
expect(normalize(" \n\n \t ").text).toBe("");
});
});

View File

@@ -1,49 +0,0 @@
// Canonical text normalization for selectors and stored quotes.
// Contract: wiki/SharedContracts.md §6.
//
// IMPORTANT: NORMALIZE_VERSION is stored on every Annotation. Bumping it is a
// migration event — old selectors must be re-resolved against re-normalized
// text before the new version becomes the default.
export const NORMALIZE_VERSION = 1;
// Soft hyphen (U+00AD), optionally followed by a single \n so that a PDF-
// extracted "word­\nfragment" reassembles to "wordfragment" rather than
// leaving a stray line break in the middle of a hyphenated word.
const SOFT_HYPHEN_AT_BREAK = /­\n?/g;
// Horizontal whitespace = any \s except \n and \r. The double-negation
// [^\S\r\n] is the idiomatic regex: \S is "not whitespace", so
// "not (not-whitespace or line-ending)" = "whitespace that is not a newline".
// Covers space, tab, NBSP, narrow NBSP, em-space, all Zs general-category.
const HORIZONTAL_WHITESPACE_RUN = /[^\S\r\n]+/g;
// 3+ newlines collapse to exactly two (one paragraph boundary).
const PARAGRAPH_RUN = /\n{3,}/g;
export function normalize(input: string): { text: string; version: number } {
// 1. Unicode NFC.
let text = input.normalize("NFC");
// 2. Normalize line endings: CRLF and CR -> LF.
text = text.replace(/\r\n?/g, "\n");
// 4. Strip soft hyphens (U+00AD) — including the line break that follows
// one — so PDF line-broken hyphenations reassemble. Done before
// horizontal collapse so no stray space remains.
text = text.replace(SOFT_HYPHEN_AT_BREAK, "");
// 3. Collapse horizontal whitespace runs to a single space.
text = text.replace(HORIZONTAL_WHITESPACE_RUN, " ");
// 5. Preserve paragraph boundaries (\n\n); collapse 3+ blank lines to 2.
text = text.replace(PARAGRAPH_RUN, "\n\n");
// Trim line-edge whitespace left over after horizontal collapse.
text = text.replace(/ +\n/g, "\n").replace(/\n +/g, "\n");
// Trim leading/trailing whitespace from the whole document.
text = text.trim();
return { text, version: NORMALIZE_VERSION };
}

View File

@@ -16,3 +16,8 @@ export {
ingestPdfFromFile, ingestPdfFromFile,
type IngestPdfFromFileOptions, type IngestPdfFromFileOptions,
} from "./pdf/upload"; } from "./pdf/upload";
export {
isEphemeralBlobUri,
resolvePdfViewerUrl,
documentHasUploadedBytes,
} from "./pdf/viewer-url";

View File

@@ -49,7 +49,7 @@ beforeAll(async () => {
); );
}); });
describe("ingestPdf — fixture corpus", () => { describe("ingestPdf — fixture corpus", { timeout: 30_000 }, () => {
for (const fixture of FIXTURES) { for (const fixture of FIXTURES) {
describe(fixture.id, () => { describe(fixture.id, () => {
const path = resolve(FIXTURE_DIR, fixture.filename); const path = resolve(FIXTURE_DIR, fixture.filename);

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { Document } from "@shared/document";
import type { DocumentId } from "@shared/ids";
import { createPdfByteStore } from "./byte-store";
import { isEphemeralBlobUri, resolvePdfViewerUrl } from "./viewer-url";
function doc(overrides: Partial<Document> = {}): Document {
return {
id: "doc_1" as DocumentId,
mediaType: "application/pdf",
title: "sample.pdf",
createdAt: "2026-06-07T00:00:00.000Z",
updatedAt: "2026-06-07T00:00:00.000Z",
...overrides,
};
}
describe("resolvePdfViewerUrl", () => {
it("prefers live byte-store blob over persisted document.uri", () => {
const store = createPdfByteStore({
createObjectURL: () => "blob:live",
revokeObjectURL: () => {},
});
store.put("doc_1" as DocumentId, new Uint8Array([1]));
const url = resolvePdfViewerUrl(
doc({ uri: "blob:stale-from-localStorage" }),
store,
);
expect(url).toBe("blob:live");
});
it("uses non-blob document.uri when byte store is empty", () => {
const store = createPdfByteStore();
expect(resolvePdfViewerUrl(doc({ uri: "file:///x.pdf" }), store)).toBe(
"file:///x.pdf",
);
});
it("falls back to fixture path when only a stale blob uri is stored", () => {
const store = createPdfByteStore();
expect(
resolvePdfViewerUrl(doc({ uri: "blob:revoked", title: "a.pdf" }), store),
).toBe("/fixtures/pdfs/a.pdf");
});
});
describe("isEphemeralBlobUri", () => {
it("detects blob URLs", () => {
expect(isEphemeralBlobUri("blob:http://localhost/x")).toBe(true);
expect(isEphemeralBlobUri("/fixtures/pdfs/x.pdf")).toBe(false);
});
});

View File

@@ -0,0 +1,43 @@
/**
* Resolve the URL the PDF viewer should load for a document.
*
* Uploaded PDFs are served via ephemeral `blob:` URLs owned by
* `PdfByteStore`. Those URLs must not be persisted (see persistence
* sanitization) and must be re-resolved from live bytes on every render.
*/
import type { Document } from "@shared/document";
import type { DocumentId } from "@shared/ids";
import type { PdfByteStore } from "./byte-store";
export function isEphemeralBlobUri(uri: string | undefined): boolean {
return typeof uri === "string" && uri.startsWith("blob:");
}
/**
* Prefer the byte store's live blob URL for uploaded documents; fall back
* to a stable HTTP fixture path or a non-blob `document.uri`.
*/
export function resolvePdfViewerUrl(
document: Document,
byteStore: PdfByteStore,
): string | null {
const live = byteStore.get(document.id);
if (live) return live.blobUrl;
if (document.uri && !isEphemeralBlobUri(document.uri)) {
return document.uri;
}
const titleOrId = document.title ?? document.id;
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
}
/** True when bytes exist in the store but the document record lacks a URI. */
export function documentHasUploadedBytes(
documentId: DocumentId,
byteStore: PdfByteStore,
): boolean {
return byteStore.has(documentId);
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,100 +0,0 @@
/**
* AnnotationToolbar — wires "I selected text" into "evidence appears in
* the sidebar".
*
* Visible only when a `pendingSelection` is set (the viewer publishes
* captures into context, then this toolbar lets the user attach commentary
* and commit). On Save it runs the full pipeline:
*
* 1. `createSelectors(capture, representation)` — anchor builds the
* maximal selector set against the active representation.
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
* emits AnnotationCreated.
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem with
* the user's commentary, emits EvidenceItemCreated.
*
* The sidebar re-renders via the engine event bus, so no other glue is
* needed.
*/
import { useEffect, useState } from "react";
import { createSelectors } from "@anchor/index";
import {
useActiveDocument,
useEngine,
usePendingSelection,
} from "./EngineContext";
export function AnnotationToolbar() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { pending, set } = usePendingSelection();
const [commentary, setCommentary] = useState("");
// Reset the commentary box whenever a fresh selection arrives.
useEffect(() => {
setCommentary("");
}, [pending]);
if (!pending || !document || !representation) return null;
const handleSave = () => {
const selectors = createSelectors(pending.capture, representation);
const annotation = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors,
quote: pending.capture.text,
});
engine.evidence.create({
annotationIds: [annotation.id],
...(commentary.trim().length > 0 ? { commentary: commentary.trim() } : {}),
});
set(null);
};
const handleDiscard = () => set(null);
const quote = pending.capture.text;
const shortQuote = quote.length > 200 ? `${quote.slice(0, 200)}` : quote;
return (
<div
style={{
borderBottom: "1px solid #f0c040",
background: "#fff8d6",
padding: 8,
fontFamily: "system-ui, sans-serif",
fontSize: 12,
}}
>
<div style={{ marginBottom: 6, fontWeight: 600 }}>
New annotation ({pending.selectors.length} selector{pending.selectors.length === 1 ? "" : "s"})
</div>
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
&ldquo;{shortQuote}&rdquo;
</div>
<textarea
value={commentary}
onChange={(e) => setCommentary(e.target.value)}
placeholder="Add a one-line comment (optional)…"
rows={2}
style={{
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={handleSave} style={{ fontSize: 12, padding: "4px 10px" }}>
Save evidence
</button>
<button onClick={handleDiscard} style={{ fontSize: 12, padding: "4px 10px" }}>
Discard
</button>
</div>
</div>
);
}

View File

@@ -106,13 +106,16 @@ export function CollectionList({ upload, title }: CollectionListProps) {
<li key={doc.id} style={{ marginBottom: 6 }}> <li key={doc.id} style={{ marginBottom: 6 }}>
<div <div
style={{ style={{
border: "1px solid #ccc", // Always-blue palette to mirror the evidence-card
background: isActive ? "#e8f0ff" : "white", // "always-yellow + thicker border when active" pattern.
border: isActive ? "3px solid #0050b3" : "1px solid #0050b3",
background: "#e8f0ff",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
fontSize: 12, fontSize: 12,
}} }}
data-testid={`collection-item-${doc.id}`} data-testid={`collection-item-${doc.id}`}
data-active={isActive ? "true" : "false"}
> >
<button <button
onClick={() => setId(doc.id)} onClick={() => setId(doc.id)}

View File

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

View File

@@ -1,18 +1,27 @@
/** /**
* EvidenceSidebar — the right pane. * EvidenceSidebar — the right pane.
* *
* Lists `EvidenceItem`s scoped to the currently-active document. Each row * Lists `EvidenceItem`s scoped to the active document, sorted by their
* shows quote + commentary + status. Clicking a row emits * position in the document (first PdfRectSelector's page + y). Each row:
* `EvidenceItemActivated` via the engine, which T08 will translate into a
* scroll-to-passage in the viewer.
* *
* CE-WP-0004-T04 added: a per-item Export popover (Copy as Markdown / * - Click → activates the evidence item (highlights its passage in
* Copy as HTML), a transient toast confirming the copy, and the * the viewer + thickens its border).
* Cmd/Ctrl+Shift+C keyboard shortcut that exports the currently-active * - Edit pencil → inline form to change the citation quote and
* evidence as Markdown. * commentary. The underlying selectors stay untouched, so the
* marked passage in the document doesn't move.
* - Export popover → copy as Markdown / HTML (CE-WP-0004).
*
* The "create new evidence from a fresh selection" form
* (`InlineCaptureForm`) is slotted into the list at the right
* document-flow position whenever there is a pending selection — so a
* new capture appears between the cards that bracket it, or at the
* top/bottom if it's the first or last passage in the document.
*
* Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
*/ */
import { import {
Fragment,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -20,14 +29,18 @@ import {
useState, useState,
type CSSProperties, type CSSProperties,
} from "react"; } from "react";
import type { Annotation } from "@shared/annotation";
import type { EvidenceItem } from "@shared/evidence"; import type { EvidenceItem } from "@shared/evidence";
import type { EvidenceItemId } from "@shared/ids"; import type { AnnotationId, EvidenceItemId } from "@shared/ids";
import type { PdfRectSelector, Selector } from "@shared/selector";
import { import {
useActiveDocument, useActiveDocument,
useEngine, useEngine,
useEngineEventTick, useEngineEventTick,
useEngineRevision, useEngineRevision,
useLastActivatedEvidence, useLastActivatedEvidence,
usePendingSelection,
useScrollToAnnotation, useScrollToAnnotation,
} from "./EngineContext"; } from "./EngineContext";
import { import {
@@ -35,6 +48,8 @@ import {
type ExportFormat, type ExportFormat,
type ExportResult, type ExportResult,
} from "./useExportEvidence"; } from "./useExportEvidence";
import { EvidenceFormBody } from "./EvidenceFormBody";
import { InlineCaptureForm } from "./InlineCaptureForm";
const TOAST_TIMEOUT_MS = 2000; const TOAST_TIMEOUT_MS = 2000;
@@ -45,7 +60,6 @@ export interface EvidenceSidebarProps {
interface ToastState { interface ToastState {
readonly message: string; readonly message: string;
readonly tone: "success" | "error"; readonly tone: "success" | "error";
/** Bumps on every new toast so timers don't dismiss the *next* toast. */
readonly key: number; readonly key: number;
} }
@@ -67,23 +81,84 @@ function describeSuccess(format: ExportFormat): string {
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML"; return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
} }
/**
* A sortable scalar key for "where in the document is this passage".
* Page-first, then y-coordinate (0..1 within the page). Returns
* Infinity for items without a usable position so they sink to the
* bottom. The same scheme is used for `EvidenceItem`s (via their
* first annotation) and for the pending selection's capture.
*/
function docOrderKey(selectors: readonly Selector[]): number {
for (const s of selectors) {
if (s.type === "PdfRectSelector") {
const rect: PdfRectSelector = s;
const top = rect.rects[0]?.y ?? 0;
return rect.page * 1000 + top;
}
}
return Number.POSITIVE_INFINITY;
}
function annotationOrderKey(annotation: Annotation | null): number {
if (!annotation) return Number.POSITIVE_INFINITY;
return docOrderKey(annotation.selectors);
}
export function EvidenceSidebar(props: EvidenceSidebarProps) { export function EvidenceSidebar(props: EvidenceSidebarProps) {
const engine = useEngine(); const engine = useEngine();
const { document } = useActiveDocument(); const { document } = useActiveDocument();
const { scrollTo } = useScrollToAnnotation(); const { scrollTo } = useScrollToAnnotation();
const activeId = useLastActivatedEvidence(); const activeId = useLastActivatedEvidence();
const { exportItem } = useExportEvidence(); const { exportItem } = useExportEvidence();
const { pending } = usePendingSelection();
const createTick = useEngineEventTick("EvidenceItemCreated"); const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated"); const updateTick = useEngineEventTick("EvidenceItemUpdated");
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
const revision = useEngineRevision(); const revision = useEngineRevision();
const items = useMemo<readonly EvidenceItem[]>(() => { // Build the sorted view-model: each item gets its order key + the
if (!document) return []; // first annotation up-front so the render below doesn't have to
return engine.evidence.listByDocument(document.id); // re-resolve them inside the map.
}, [document, engine, createTick, updateTick, revision]); const sortedItems = useMemo(() => {
if (!document) return [] as readonly { item: EvidenceItem; annotation: Annotation | null; order: number }[];
const items = engine.evidence.listByDocument(document.id);
const out = items.map((item) => {
const firstAnnId = item.annotationIds[0];
const annotation = firstAnnId ? engine.annotations.get(firstAnnId) : null;
return { item, annotation, order: annotationOrderKey(annotation) };
});
out.sort((a, b) => a.order - b.order);
return out;
}, [
document,
engine,
createTick,
updateTick,
annotationUpdateTick,
revision,
]);
const pendingOrder = useMemo<number>(() => {
if (!pending) return Number.POSITIVE_INFINITY;
const c = pending.capture;
return c.page * 1000 + (c.boundingRect?.y ?? 0);
}, [pending]);
// Find the insert position for the pending capture form: first index
// whose order > pendingOrder, or sortedItems.length to append.
const pendingInsertIndex = useMemo(() => {
if (!pending) return -1;
for (let i = 0; i < sortedItems.length; i++) {
if (sortedItems[i]!.order > pendingOrder) return i;
}
return sortedItems.length;
}, [pending, pendingOrder, sortedItems]);
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null); const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
const [editingId, setEditingId] = useState<EvidenceItemId | null>(null);
const [editQuote, setEditQuote] = useState("");
const [editCommentary, setEditCommentary] = useState("");
const [toast, setToast] = useState<ToastState | null>(null); const [toast, setToast] = useState<ToastState | null>(null);
const toastKeyRef = useRef(0); const toastKeyRef = useRef(0);
@@ -127,6 +202,48 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [activeId, engine, runExport]); }, [activeId, engine, runExport]);
const activateItem = useCallback(
(item: EvidenceItem, firstAnnotationId: AnnotationId | undefined) => {
engine.evidence.activate(item.id, "sidebar");
if (firstAnnotationId) scrollTo(firstAnnotationId);
props.onActivate?.(item);
},
[engine, scrollTo, props],
);
const beginEdit = useCallback(
(item: EvidenceItem, annotation: Annotation | null) => {
setEditingId(item.id);
setEditQuote(annotation?.quote ?? "");
setEditCommentary(item.commentary ?? "");
setOpenExportFor(null);
},
[],
);
const cancelEdit = useCallback(() => {
setEditingId(null);
}, []);
const saveEdit = useCallback(
(item: EvidenceItem, annotation: Annotation | null) => {
try {
if (annotation) {
engine.annotations.updateQuote(annotation.id, editQuote);
}
// updateCommentary expects a string — empty string clears it.
engine.evidence.updateCommentary(item.id, editCommentary);
setEditingId(null);
} catch (err) {
showToast(
err instanceof Error ? `Save failed: ${err.message}` : "Save failed",
"error",
);
}
},
[engine, editQuote, editCommentary, showToast],
);
return ( return (
<aside <aside
style={{ style={{
@@ -143,138 +260,62 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
{!document && ( {!document && (
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p> <p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
)} )}
{document && items.length === 0 && ( {document && sortedItems.length === 0 && !pending && (
<p style={{ fontSize: 12, color: "#888" }}> <p style={{ fontSize: 12, color: "#888" }}>
No evidence yet. Select a passage in the viewer to create one. No evidence yet. Drag-select a passage in the viewer to start a
capture.
</p> </p>
)} )}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}> <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{items.map((item) => { {sortedItems.map((entry, i) => {
const firstAnnotationId = item.annotationIds[0]; const slotForCapture = pendingInsertIndex === i;
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 ( return (
<li key={item.id} style={{ marginBottom: 8 }}> <Fragment key={entry.item.id}>
<div {slotForCapture && (
style={{ <li>
position: "relative", <InlineCaptureForm />
background: isActive ? "#e8f0ff" : "#fff8d6",
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
borderRadius: 2,
}}
>
<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>
</li> </li>
)}
<li style={{ marginBottom: 8 }}>
<EvidenceCard
item={entry.item}
annotation={entry.annotation}
isActive={activeId === entry.item.id}
isExportOpen={openExportFor === entry.item.id}
isEditing={editingId === entry.item.id}
editQuote={editQuote}
editCommentary={editCommentary}
onActivate={() =>
activateItem(entry.item, entry.annotation?.id)
}
onBeginEdit={() => beginEdit(entry.item, entry.annotation)}
onChangeQuote={setEditQuote}
onChangeCommentary={setEditCommentary}
onSaveEdit={() => saveEdit(entry.item, entry.annotation)}
onCancelEdit={cancelEdit}
onToggleExport={() =>
setOpenExportFor((current) =>
current === entry.item.id ? null : entry.item.id,
)
}
onCopyMarkdown={async () => {
setOpenExportFor(null);
await runExport(entry.item, "markdown");
}}
onCopyHtml={async () => {
setOpenExportFor(null);
await runExport(entry.item, "html");
}}
/>
</li>
</Fragment>
); );
})} })}
{pendingInsertIndex === sortedItems.length && (
<li>
<InlineCaptureForm />
</li>
)}
</ul> </ul>
{toast && ( {toast && (
<div <div
@@ -302,6 +343,179 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
); );
} }
interface EvidenceCardProps {
readonly item: EvidenceItem;
readonly annotation: Annotation | null;
readonly isActive: boolean;
readonly isExportOpen: boolean;
readonly isEditing: boolean;
readonly editQuote: string;
readonly editCommentary: string;
onActivate(): void;
onBeginEdit(): void;
onChangeQuote(next: string): void;
onChangeCommentary(next: string): void;
onSaveEdit(): void;
onCancelEdit(): void;
onToggleExport(): void;
onCopyMarkdown(): Promise<void>;
onCopyHtml(): Promise<void>;
}
function EvidenceCard(p: EvidenceCardProps) {
const quote = p.annotation?.quote ?? "(no quote)";
return (
<div
data-testid={`evidence-card-${p.item.id}`}
data-active={p.isActive ? "true" : "false"}
style={{
position: "relative",
background: "#fff8d6",
border: p.isActive ? "3px solid #b78b1c" : "1px solid #e0c050",
borderRadius: 2,
}}
>
{p.isEditing ? (
<EvidenceFormBody
quote={p.editQuote}
commentary={p.editCommentary}
onChangeQuote={p.onChangeQuote}
onChangeCommentary={p.onChangeCommentary}
onSave={p.onSaveEdit}
onCancel={p.onCancelEdit}
saveLabel="Save"
cancelLabel="Cancel"
helper="The marked passage in the document stays the same."
testidPrefix={`evidence-edit-${p.item.id}`}
/>
) : (
<>
<button
type="button"
onClick={p.onActivate}
aria-current={p.isActive ? "true" : undefined}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
paddingRight: 96,
cursor: "pointer",
fontSize: 12,
}}
>
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</div>
{p.item.commentary && (
<div style={{ color: "#333", marginBottom: 4 }}>
{p.item.commentary}
</div>
)}
<div style={{ color: "#666", fontSize: 11 }}>
status: {p.item.status}
</div>
</button>
<div
style={{
position: "absolute",
top: 6,
right: 6,
display: "flex",
gap: 4,
}}
>
<button
type="button"
aria-label="Edit citation and commentary"
data-testid={`evidence-edit-toggle-${p.item.id}`}
onClick={(e) => {
e.stopPropagation();
p.onBeginEdit();
}}
title="Edit citation and commentary"
style={iconButtonStyle}
>
</button>
<button
type="button"
aria-haspopup="menu"
aria-expanded={p.isExportOpen}
aria-label="Export evidence item"
data-testid={`export-toggle-${p.item.id}`}
onClick={(e) => {
e.stopPropagation();
p.onToggleExport();
}}
style={iconButtonStyle}
>
Export
</button>
</div>
{p.isExportOpen && (
<div
role="menu"
data-testid={`export-menu-${p.item.id}`}
style={{
position: "absolute",
top: 28,
right: 6,
zIndex: 10,
background: "white",
border: "1px solid #888",
borderRadius: 3,
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
padding: 4,
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 160,
}}
>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
await p.onCopyMarkdown();
}}
style={menuButtonStyle}
>
Copy as Markdown
</button>
<button
type="button"
role="menuitem"
onClick={async (e) => {
e.stopPropagation();
await p.onCopyHtml();
}}
style={menuButtonStyle}
>
Copy as HTML
</button>
</div>
)}
</>
)}
</div>
);
}
const iconButtonStyle: CSSProperties = {
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
lineHeight: 1,
};
const menuButtonStyle: CSSProperties = { const menuButtonStyle: CSSProperties = {
textAlign: "left", textAlign: "left",
background: "transparent", background: "transparent",

Some files were not shown because too many files have changed in this diff Show More