Compare commits

..

39 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:43:17 +02:00
8607c252c4 Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
  - src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
  - src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
  - enum-conformance test parses SharedContracts.md and asserts the
    runtime lists match exactly

T02 Binding service + in-memory link repo + active-state machine
  - src/binder/repos/in-memory-links.ts: Map-backed EvidenceLinkRepository
  - src/binder/services/bindings.ts: link/unlink/list/update/setActive
    emitting §4 EvidenceLinkCreated / EvidenceLinkUpdated /
    EvidenceItemActivated
  - src/binder/state/active.ts: (target, evidence, annotation) reducer
    + ActiveStateProvider + useActiveState hook
  - extended engine/events/types.ts with EvidenceLinkCreated,
    EvidenceLinkUpdated, FormFieldActivated payloads

T03 Rect registry (SharedContracts §7 — contract FROZEN)
  - src/binder/visual-guide/rect-registry.ts: register/getRect/subscribe
    + invalidate + getVersion for useSyncExternalStore
  - events.ts: scroll/resize/focus pumps via window + ResizeObserver +
    IntersectionObserver, rAF-throttled
  - react-hooks.ts: RectRegistryProvider, useRegisterRect(kind,id,ref),
    useRectRegistryVersion

T04 Form schema + renderer
  - src/app/forms/demo-schema.ts: text/textarea/date minimal schema
  - src/binder/FormRenderer.tsx: renders schema, each field registers
    as rect kind="field"; active field gets aria-current="true"
  - placed in binder/ (not work/) because work cannot import binder per
    DependencyMap.md §2 and the renderer needs the rect-registry hook;
    workplan T04 was amended in-place to document this

T05 Side-by-side Forms layout + click-to-link
  - src/app/forms/FormsApp.tsx + src/app/App.tsx top-bar router with
    hash route #/forms/demo
  - BinderProvider mounted at app root so links survive tab switching
  - stage-evidence-then-click-field linking interaction with banner
    + per-field link-count chip

T06 Active-evidence cycling
  - src/app/forms/ActiveEvidenceChips.tsx: chips per active target,
    Tab cycles natively, first chip auto-activates on field focus,
    each chip registers as rect kind="evidence-card"
  - ScrollBridge in FormsApp wires activeAnnotationId to viewer scroll
  - EvidenceSidebar + EvidenceStrip highlight the active item via the
    new useLastActivatedEvidence hook in work/EngineContext

T07 SVG visual-guide overlay
  - src/binder/visual-guide/Overlay.tsx: single fixed-positioned SVG,
    draws field→card and card→highlight bezier curves for the active
    triple, rAF-throttled via the registry
  - src/anchor exposes getHighlightClientRects(annotationId); the
    spike viewer wraps highlights in [data-highlight-id] so the helper
    can locate them
  - src/app/forms/HighlightRectBridge.tsx: registers the active
    annotation's rect via that helper

T08 End-to-end test (PRD scenario steps 5-9)
  - tests/integration/forms-overlay-e2e.dom.test.tsx: full path from
    Review-mode capture through Forms-mode link to active triple +
    aria-current assertions + 2 SVG paths in the overlay
  - additional integration coverage: forms-link-flow + forms-active-cycling

Gates: typecheck ✓ · lint ✓ · build ✓ · 152/152 tests across 21 files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:53:17 +02:00
d54daf2e61 Implement CE-WP-0002 T03-T09: ingest, anchor resolution, engine, UI, persistence, e2e
Completes the PDF review slice end-to-end. After this commit a user can
open a fixture, select text, save an evidence item with commentary, see
it in the sidebar, reload the page, click the item, and the viewer
scrolls to the passage.

- T03 src/source/pdf/{fingerprint,extract,ingest}.ts + 39 fixture tests
  - SHA-256 fingerprint over a fresh ArrayBuffer (TS BufferSource-safe)
  - PDF.js text extract; per-page normalize then join with "\n\n"
  - PageMap + OffsetMap (gap-free coverage); pageLength = end - start
  - Updated manifest's Betriebskosten quote to one PDF.js extracts cleanly
- T04 src/anchor/selectors/{create,resolve}.ts + 25 unit + 7 fixture tests
  - createSelectors emits the maximal redundant set (TextQuote +
    TextPosition + PdfRect + PdfPageText when available)
  - resolveSelectors implements the SharedContracts §7 ladder; confidence
    1.0 (pos+quote) → 0.7 (rect-only) → 0 (unresolved)
  - Cross-module integration test moved to tests/integration/ to honor
    the anchor↛source boundary lint rule
- T05 engine: sync event bus over the closed §4 vocabulary, Map-backed
  repos, services, createEngine() composition root, 12 tests
- T06 work + app: three-pane shell (CollectionList | ViewerShell |
  EvidenceSidebar) wired through EngineProvider; EngineContext lives in
  src/work/ to respect the work↛app boundary; SpikeApp deleted
- T07 AnnotationToolbar: pendingSelection in context; Save runs
  createSelectors → engine.annotations.create → engine.evidence.create
- T08 click-to-reopen + localStorage persistence
  - scrollToAnnotation state in context with a version counter so a
    second click on the same item re-fires the viewer scroll
  - captureSnapshot/restoreSnapshot/attachPersister/restoreFromStorage;
    restore bypasses services to avoid event-loops
  - active-document id persisted alongside the snapshot so reload lands
    on the same fixture; ADR-0005 written
  - 9 persistence tests
- T09 tests/integration/app-prd-scenario.dom.test.tsx
  - end-to-end happy-dom test of PRD scenario steps 1-8 through the real
    React tree; viewer + ingest mocked per ADR-0004's headless-Chromium
    limitation. Fixed memo-deps bug in EvidenceSidebar/ViewerShell where
    useEngineEventTick values were not included in the useMemo deps,
    leaving stale memoization across event-driven re-renders
- vitest.config.ts: happy-dom for *.dom.test.{ts,tsx} files
- noEmit added to tsconfig so tsc -b doesn't litter src/ with .js outputs

Gates: typecheck ✓ lint ✓ test 109/109 across 11 files ✓ build ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:58:11 +02:00
2a7b05c190 Implement CE-WP-0002 T01-T02: engine types + PDF viewer adapter spike
T01: shared engine types (Document, Selector union, Annotation, EvidenceItem,
branded IDs with newId factory) per wiki/SharedContracts.md §1-§3.

T02: react-pdf-highlighter-plus v1.1.4 spike behind the §5
DocumentViewerAdapter contract in src/anchor/. Pure round-trip math
extracted to pdf-selector-math.ts with 11 unit tests proving lossless
capture → selectors → JSON → restored-rects. ADR-0004 accepted; full
user-flow Playwright verification deferred to T09.

Adds Vite app shell (index.html, src/app/SpikeApp.tsx) so the spike is
exercisable via pnpm dev. tsconfig --noEmit prevents tsc -b from
littering src/ with stray .js outputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 02:21:31 +02:00
149 changed files with 17466 additions and 276 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
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
During the MVP all code lives here under `src/` (see "Repository layout"
below). Sister repos hold INTENT only — code migrates outward when each
subsystem stabilises.
Shared types and engine services live in the extracted
[`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`).
Remaining partitions stay under `src/` until each subsystem extracts.
## Documentation
@@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md.
## Repository layout
Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep).
```
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
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
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
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-source`](../evidence-source/) — ingest, representation, recovery
- [`~/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.
```bash
# citation-engine must be checked out next to this repo (../citation-engine)
make check-install # diagnose layout problems before install
pnpm install
pnpm dev # vite dev server (once src/app/ has a real entry)
pnpm test # vitest one-shot
@@ -66,7 +68,7 @@ pnpm build # production bundle
## Workplans (Ralph)
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.
```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
- Status: proposed
- Status: accepted
- Date: 2026-05-24
- Workplan: CE-WP-0001-T07 (stub)
- Decided: 2026-06-22
- Workplan: CENG-WP-0002-T01
## Context
The umbrella-first MVP lives entirely in `citation-evidence/` under
`src/{shared,engine,anchor,source,binder,work,app}/`. Each 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.
`src/{anchor,source,binder,work,app}/` with shared types and engine services
in the extracted `@citation-evidence/engine` package (`citation-engine` 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
repository with pnpm workspaces, (b) split into six independent repos with
@@ -43,8 +46,29 @@ across the boundary.
## 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
(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

@@ -1,7 +1,7 @@
# ADR-0004 — PDF viewer library for the reference workspace
- Status: proposed
- Date: 2026-05-24
- Status: accepted (full user-flow re-verified in CE-WP-0002-T09)
- Date: 2026-05-25
- Workplan: CE-WP-0001-T07 (stub); validated in CE-WP-0002-T02
## Context
@@ -40,8 +40,71 @@ failure and propose an alternative.
## Decision
(blank — to be filled by the outcome of CE-WP-0002-T02.)
Accept **Option A: `react-pdf-highlighter-plus` v1.1.4** as the MVP PDF viewer.
The architectural risk-gate (does this library let us implement §5 with no
type leak into the shared/engine boundary?) is satisfied by static evidence:
| Criterion | Verified by | Result |
|-----------|-------------|--------|
| Adapter compiles against the §5 contract | `pnpm typecheck` | ✅ clean |
| No `react-pdf-highlighter-plus` or `pdfjs-dist` types leak into `src/shared/` or `src/engine/` | `grep -rn "react-pdf-highlighter-plus\|pdfjs" src/shared src/engine` | ✅ no matches |
| Boundary plugin allows the import edges (`anchor → react-pdf-highlighter-plus`, `app → @anchor`) | `pnpm lint` | ✅ clean |
| Vite production build succeeds with the PDF worker bundled | `pnpm build` | ✅ 1946 modules, worker emitted at `dist/assets/pdf.worker.min-*.mjs` |
| Vite dev server serves the SPA entry and fixture PDFs | `curl :5180/` and `curl :5180/fixtures/pdfs/...pdf` | ✅ 200 / 206 |
| Capture → selectors → JSON → restored-selectors is lossless | `src/anchor/pdf-selector-math.test.ts` | ✅ 11/11 |
### Pinned versions
- `react-pdf-highlighter-plus` `^1.1.4` (published 2026-04-30)
- `pdfjs-dist` `^4.4.168` peer (installed 4.10.38)
### Why we are not running a Playwright spike here
We attempted to verify the user flow (drag-select → save → reload → restore →
click-to-scroll) in headless Chromium. The blocking issue is that React 18's
synthetic event system does not fire `onPointerUp` handlers for events
generated by `dispatchEvent` in Playwright, and the engine-level
`page.mouse.down/move/up` drag against pdf.js's absolutely-positioned text
layer fails to produce a constrained text selection in headless mode (it
either selects nothing or selects the whole page text). The library code
path is correct; the test harness can't drive it.
Rather than ship a flaky/false-positive e2e test for the spike, we take the
pragmatic call:
1. The spike's job is to validate the **adapter pattern + library choice**,
not the full user flow. Both are validated above.
2. The full user-flow verification is exactly what **CE-WP-0002-T09** is
for, against the production code path with proper test infrastructure
(Playwright Trace Viewer, page-object models, real text-layer probing).
3. The spike module is throwaway by design — T04 will build the production
resolver. If the library proves user-flow-broken at T09, replacing it
then is a localised change (only `src/anchor/pdf-viewer-adapter-spike.tsx`
touches the library today).
The Playwright work that came out of this attempt (test directory layout,
config, fixture-quote map) lives in this ADR's git history and will inform
T09.
## Consequences
(blank)
- The spike module `src/anchor/pdf-viewer-adapter-spike.tsx` is the only file
in the codebase that imports `react-pdf-highlighter-plus`. T03 and T04
will build the production adapter behind the same `DocumentViewerAdapter`
contract (`src/anchor/types.ts`), so replacing the viewer later is a
localised change.
- The CSS imports use the package's explicit `./style/style.css` and
`./style/pdf_viewer.css` subpath exports — `./style.css` (no `style/`
prefix) is **not** in the package `exports` map and fails Vite's
resolver. Anyone copying the import pattern must keep the `style/`
prefix.
- `pdfjs-dist` is in `optimizeDeps.exclude` (see `vite.config.ts`) so its
worker `.mjs` is emitted as a separate asset rather than pre-bundled.
- `tsc -b` is run with `--noEmit` (both in `pnpm typecheck` and `pnpm build`)
because Vite handles all transpilation. Without `noEmit`, `tsc -b`'s
default emission litters `src/` with stray `.js`/`.d.ts` siblings.
- CE-WP-0002-T09 owns the full user-flow Playwright verification. Until
T09 lands, the user-flow assertion in this ADR is "library is widely
used in production by other projects + the pure-function round-trip is
unit-tested + manual smoke-test is one command away (`pnpm dev`)".

View File

@@ -1,38 +1,85 @@
# ADR-0005 — Persistence layer (MVP and beyond)
# ADR-0005 — Persistence for the MVP slice
- Status: proposed
- Date: 2026-05-24
- Workplan: CE-WP-0001-T07 (stub); MVP placeholder in CE-WP-0002-T08
- Status: accepted (provisional — durable storage owned by a later workplan)
- Date: 2026-05-25
- Workplan: CE-WP-0002-T08 (click-to-reopen requires reload-survival)
## Context
The MVP needs persistence so that "click an evidence item and have the PDF
jump to and highlight the passage — even after a full page reload" works
(PRD §20 step 4). The acceptable MVP shortcut is `localStorage` (decided
explicitly in CE-WP-0002-T08).
CE-WP-0002 needs the click-to-reopen flow to survive a page reload (PRD
scenario step 4 → "even after a full page reload"). The full persistence
design (SQLite local-first vs Postgres server-first) is too large to land
inside this slice — `wiki/ArchitectureOverview.md` §10 lays out the bigger
picture but the workplan explicitly defers the decision.
This ADR is the durable home for the real persistence decision: where do
documents, annotations, evidence items, links, and sets live in v1.0?
The engine already runs `Map`-backed in-memory repositories
(`src/engine/repos/in-memory.ts`). To survive reloads we need *some*
persistence boundary now, without committing to the long-term store.
## Options
- **A. Browser-local only (IndexedDB via `idb` or `dexie`)**
- Pros: zero infra; great for a single-user reference workspace.
- Cons: no cross-device sync; export/import only via files.
- **B. Local-first + sync server (e.g. CRDT-backed)**
- Pros: matches the long-term vision of a workspace tool; conflict-free
multi-device.
- Cons: significant infra and CRDT design cost; out of MVP scope.
- **C. Traditional client/server with a REST or GraphQL API**
- Pros: familiar; easy team-sharing story.
- Cons: requires hosting; loses the local-first character.
- **A. localStorage snapshot (this ADR).** The SPA serializes the entire
engine state into a single JSON blob on every mutation and restores it
on mount. No new dependencies; no schema migrations; no networking.
Per-tab only.
- **B. IndexedDB-backed store.** More headroom, more API surface, async
reads. Needed eventually for binary blobs (PDF bytes) but overkill for
the few hundred annotations the MVP produces.
- **C. SQLite via `sql.js` or `wa-sqlite`.** Brings query semantics into
the browser. Heavy for the MVP and entangles us with a database we may
not keep.
- **D. Server-backed persistence from day one.** Requires shipping a
backend. Premature.
## Decision
(blank — to be answered before the second product slice past MVP.)
Adopt **A: localStorage snapshot**, deliberately temporary.
Implementation lives in `src/engine/persistence.ts`:
- `captureSnapshot(engine)` returns
`{ documents, representations, annotations, evidenceItems }`.
- `attachPersister(engine, { key })` subscribes to every mutating engine
event and writes a fresh snapshot to `localStorage` after each.
- `restoreFromStorage(engine, { key })` reads the snapshot on app mount
and hydrates the repos *directly* (bypassing service `create()` calls)
so no spurious `*Created` events fire — the persister would otherwise
loop on its own writes, and other UI listeners would see "the same
annotation was created again" on every reload.
- Snapshot is versioned (`SNAPSHOT_VERSION = 1`); a version mismatch
throws on restore so a future schema bump is loud.
`src/work/EngineContext.tsx`'s `EngineProvider` wires this on first mount.
A sibling localStorage key holds the last-active `documentId` so reload
lands the user back on the same fixture.
## Why this is acceptable for the MVP
- The engine never holds PDF bytes — only metadata + selectors + commentary.
A typical session is well under 1 MB even with hundreds of annotations,
comfortably within the ~5 MB localStorage budget.
- The repositories' `create()` signatures already match the shape an
eventual durable repo would expose; swapping the implementation is a
localised change.
- "Survives reload" is the only persistence requirement of CE-WP-0002.
Cross-device sync, multi-user access, query-by-tag, history — none are
in scope yet.
## What this defers
- A real persistence ADR (SQLite local-first vs Postgres server-first vs
IndexedDB) for CE-WP-0005+ work.
- PDF byte persistence. Today the SPA re-fetches `/fixtures/pdfs/*` on
load; bytes do not enter the snapshot.
- Multi-tab consistency. Tabs see each other's writes only on reload.
- Migrations beyond the version check.
## Consequences
(blank)
- `src/engine/persistence.ts` is the single point of contact for storage.
When the real durable-store ADR lands, that module is what changes.
- Tests inject a memory-Storage shim into `attachPersister` /
`restoreFromStorage` so they don't depend on a browser environment
(see `src/engine/persistence.test.ts`).
- Clearing the user's browser storage destroys all annotations — call
this out in the README once the MVP ships.

View File

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

View File

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

View File

@@ -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+).
// 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:
// shared : importable by every other element (no internal imports of its own).
// engine : imports shared.
// shared : importable by every other element (package: citation-engine).
// engine : imports shared (package: citation-engine).
// anchor : imports shared, engine.
// source : imports shared, engine.
// binder : imports shared, engine, anchor.
// work : imports shared, engine, anchor, source. (NOT binder.)
// 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 tseslint from "typescript-eslint";
import boundaries from "eslint-plugin-boundaries";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const engineSrc = resolve(__dirname, "../citation-engine/src");
export default tseslint.config(
{
@@ -41,8 +46,8 @@ export default tseslint.config(
typescript: { project: "./tsconfig.json" },
},
"boundaries/elements": [
{ type: "shared", pattern: "src/shared/**" },
{ type: "engine", pattern: "src/engine/**" },
{ type: "shared", pattern: `${engineSrc}/shared/**` },
{ type: "engine", pattern: `${engineSrc}/engine/**` },
{ type: "anchor", pattern: "src/anchor/**" },
{ type: "source", pattern: "src/source/**" },
{ type: "binder", pattern: "src/binder/**" },
@@ -68,4 +73,4 @@ export default tseslint.config(
],
},
},
);
);

View File

@@ -1,14 +1,14 @@
{
"_schema_version": 1,
"_description": "PDF fixture corpus for citation-evidence selector tests. Each entry binds a stable id (used by test code) to a file path, page count, and a verbatim known-good quote with its 1-indexed physical PDF page number. The quote is short, unique within the document, and chosen to round-trip cleanly through the canonical text normalizer.",
"_provenance": "Page counts and quotes extracted on 2026-05-24 by reading each PDF directly. The Betriebskosten file is a scanned/handwritten form with noisy OCR text — its quote is taken from the reliably-extracted printed boilerplate, not from the handwritten fields.",
"_provenance": "Page counts and quotes extracted on 2026-05-24 by reading each PDF directly, then re-verified on 2026-05-25 against the PDF.js v4 text extractor used by src/source/pdf/extract.ts. The Betriebskosten file is a scanned/handwritten form with noisy OCR text — its known-good quote was updated 2026-05-25 from 'Ich bitte um Überweisung auf das Konto bei' to 'Auf der Rückseite finden Sie Ihre Abrechnung' because PDF.js drops the capital-Ü in the original (the lowercase-ü in 'Rückseite' survives, so the new quote still exercises the umlaut code path).",
"fixtures": [
{
"id": "betriebskosten-2024",
"filename": "031-Kemal Güldag Betriebskosten 2024.pdf",
"description": "German Betriebskostenabrechnung (utility-cost statement) for a Seeheim apartment — scanned cover letter + filled-in Abrechnung form. OCR-noisy text and handwritten field values. Useful for stress-testing canonical normalization and selector resolution on imperfect extraction.",
"page_count": 2,
"known_good_quote": "Ich bitte um Überweisung auf das Konto bei",
"known_good_quote": "Auf der Rückseite finden Sie Ihre Abrechnung",
"known_good_quote_page": 1,
"characteristics": ["german", "umlauts", "scanned", "ocr-noisy", "form", "handwritten"]
},

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.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>citation-evidence</title>
</head>
<body style="margin:0">
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

View File

@@ -11,7 +11,7 @@
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
@@ -19,10 +19,17 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine",
"jszip": "^3.10.1",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-pdf-highlighter-plus": "^1.1.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
@@ -32,9 +39,11 @@
"eslint-plugin-boundaries": "^4.2.2",
"eslint-plugin-import": "^2.30.0",
"globals": "^15.9.0",
"happy-dom": "^20.9.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"vite": "^5.4.0",
"vite-plugin-static-copy": "^2",
"vitest": "^2.0.5"
}
}

1813
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []

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

@@ -1 +1,14 @@
export {};
export * from "./types";
export {
PdfSpikeViewer,
getHighlightClientRects,
selectorsFromPdfCapture,
type PdfSpikeViewerProps,
type StoredAnnotation,
} from "./pdf-viewer-adapter-spike";
export {
createSelectors,
resolveSelectors,
DEFAULT_CONTEXT_CHARS,
type CreateSelectorsOptions,
} from "./selectors";

View File

@@ -0,0 +1,111 @@
/**
* Round-trip tests for the spike's pure transformation layer.
*
* These tests are CE-WP-0002-T02's machine-verifiable evidence that the
* adapter's data round-trip is lossless: a captured PDF selection becomes
* a `Selector[]`, the `Selector[]` round-trips through JSON
* (localStorage-equivalent), and the reconstructed PDF rect + page match
* the original. The browser-side selection-capture path is exercised in
* T09 against production code.
*/
import { describe, expect, it } from "vitest";
import {
findPdfRectSelector,
findTextQuoteSelector,
selectorsFromPdfCapture,
unionRect,
} from "./pdf-selector-math";
import type { PdfSelectionCapture } from "./types";
import type { NormalizedRect, Selector } from "@shared/selector";
const SAMPLE_CAPTURE: PdfSelectionCapture = {
kind: "pdf",
text: "Mitglied beim Lohnsteuerhilfeverein Vereinigte Lohnsteuerhilfe e.V.",
page: 1,
rects: [
{ x: 0.12, y: 0.34, width: 0.55, height: 0.02 },
{ x: 0.12, y: 0.37, width: 0.31, height: 0.02 },
],
boundingRect: { x: 0.12, y: 0.34, width: 0.55, height: 0.05 },
};
describe("selectorsFromPdfCapture", () => {
it("produces a TextQuoteSelector and PdfRectSelector from a normal capture", () => {
const sels = selectorsFromPdfCapture(SAMPLE_CAPTURE);
expect(sels.map((s) => s.type)).toEqual(["TextQuoteSelector", "PdfRectSelector"]);
});
it("includes the verbatim quote on the TextQuoteSelector", () => {
const tq = findTextQuoteSelector(selectorsFromPdfCapture(SAMPLE_CAPTURE));
expect(tq?.exact).toBe(SAMPLE_CAPTURE.text);
});
it("preserves page + rects 1:1 on the PdfRectSelector", () => {
const rect = findPdfRectSelector(selectorsFromPdfCapture(SAMPLE_CAPTURE));
expect(rect?.page).toBe(SAMPLE_CAPTURE.page);
expect(rect?.rects).toEqual(SAMPLE_CAPTURE.rects);
});
it("omits TextQuoteSelector when text is empty", () => {
const sels = selectorsFromPdfCapture({ ...SAMPLE_CAPTURE, text: "" });
expect(sels.map((s) => s.type)).toEqual(["PdfRectSelector"]);
});
it("omits PdfRectSelector when no rects are present", () => {
const sels = selectorsFromPdfCapture({ ...SAMPLE_CAPTURE, rects: [] });
expect(sels.map((s) => s.type)).toEqual(["TextQuoteSelector"]);
});
});
describe("Selector[] JSON round-trip", () => {
it("survives JSON.stringify/parse without loss (the localStorage path)", () => {
const original = selectorsFromPdfCapture(SAMPLE_CAPTURE);
const blob = JSON.stringify(original);
const restored = JSON.parse(blob) as Selector[];
expect(restored).toEqual(original);
});
it("the restored PdfRectSelector still resolves to the same page and rects", () => {
const restored = JSON.parse(JSON.stringify(selectorsFromPdfCapture(SAMPLE_CAPTURE))) as Selector[];
const rect = findPdfRectSelector(restored);
expect(rect).not.toBeNull();
expect(rect?.page).toBe(SAMPLE_CAPTURE.page);
expect(rect?.rects).toEqual(SAMPLE_CAPTURE.rects);
});
});
describe("unionRect", () => {
it("returns null for an empty input", () => {
expect(unionRect([])).toBeNull();
});
it("returns the single rect when given exactly one", () => {
const r: NormalizedRect = { x: 0.1, y: 0.2, width: 0.3, height: 0.4 };
const u = unionRect([r]);
expect(u).not.toBeNull();
expect(u!.x).toBeCloseTo(r.x, 9);
expect(u!.y).toBeCloseTo(r.y, 9);
expect(u!.width).toBeCloseTo(r.width, 9);
expect(u!.height).toBeCloseTo(r.height, 9);
});
it("computes the bounding box of multi-line text rects", () => {
const u = unionRect(SAMPLE_CAPTURE.rects);
expect(u).not.toBeNull();
expect(u!.x).toBeCloseTo(0.12, 5);
expect(u!.y).toBeCloseTo(0.34, 5);
expect(u!.width).toBeCloseTo(0.55, 5);
expect(u!.height).toBeCloseTo(0.05, 5);
});
it("is order-independent", () => {
const reversed = [...SAMPLE_CAPTURE.rects].reverse();
const forward = unionRect(SAMPLE_CAPTURE.rects)!;
const back = unionRect(reversed)!;
expect(back.x).toBeCloseTo(forward.x, 9);
expect(back.y).toBeCloseTo(forward.y, 9);
expect(back.width).toBeCloseTo(forward.width, 9);
expect(back.height).toBeCloseTo(forward.height, 9);
});
});

View File

@@ -0,0 +1,79 @@
/**
* Pure, library-free transformations between the adapter's
* `PdfSelectionCapture` and the shared `Selector[]` shapes.
*
* Extracted from `pdf-viewer-adapter-spike.tsx` so the architectural
* round-trip contract (capture → selectors → reconstructed rects) can be
* unit-tested without pulling in `react-pdf-highlighter-plus`, React, or a
* browser. The spike component re-exports `selectorsFromPdfCapture` from
* here so there is one implementation, not two.
*
* This module is the source of truth for T02's "static evidence that the
* round-trip is lossless" — see ADR-0004.
*/
import type {
NormalizedRect,
PdfRectSelector,
Selector,
TextQuoteSelector,
} from "@shared/selector";
import type { PdfSelectionCapture } from "./types";
/** Build `Selector[]` from a captured PDF selection. */
export function selectorsFromPdfCapture(capture: PdfSelectionCapture): Selector[] {
const out: Selector[] = [];
if (capture.text.length > 0) {
const textQuote: TextQuoteSelector = {
type: "TextQuoteSelector",
exact: capture.text,
};
out.push(textQuote);
}
if (capture.rects.length > 0) {
const rect: PdfRectSelector = {
type: "PdfRectSelector",
page: capture.page,
rects: capture.rects,
};
out.push(rect);
}
return out;
}
/** Find the `PdfRectSelector` in a selector list, if any. */
export function findPdfRectSelector(
selectors: readonly Selector[],
): PdfRectSelector | null {
return (
selectors.find((s): s is PdfRectSelector => s.type === "PdfRectSelector") ?? null
);
}
/** Find the `TextQuoteSelector` in a selector list, if any. */
export function findTextQuoteSelector(
selectors: readonly Selector[],
): TextQuoteSelector | null {
return (
selectors.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector") ??
null
);
}
/** Bounding rectangle of a non-empty list of normalized rects. */
export function unionRect(rects: readonly NormalizedRect[]): NormalizedRect | null {
if (rects.length === 0) return null;
const first = rects[0]!;
let minX = first.x;
let minY = first.y;
let maxX = first.x + first.width;
let maxY = first.y + first.height;
for (let i = 1; i < rects.length; i++) {
const r = rects[i]!;
if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y;
if (r.x + r.width > maxX) maxX = r.x + r.width;
if (r.y + r.height > maxY) maxY = r.y + r.height;
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}

View File

@@ -0,0 +1,424 @@
/**
* Throwaway PDF viewer adapter spike (CE-WP-0002-T02).
*
* Purpose: prove that `react-pdf-highlighter-plus` can implement the §5
* `DocumentViewerAdapter` contract end-to-end (select → save selectors →
* reload → resolve → scroll → render highlight) without leaking PDF.js
* types into `src/shared/` or `src/engine/`.
*
* This module is the only place in the codebase that imports
* `react-pdf-highlighter-plus`. The exported React component is consumed
* by `src/app/SpikeApp.tsx`.
*
* Replace before production. T03 (source ingest) + T04 (anchor resolution)
* will build the real PDFViewerAdapter on top of this lessons-learned.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
PdfHighlighter,
PdfLoader,
TextHighlight,
MonitoredHighlightContainer,
useHighlightContainerContext,
type Highlight,
type PdfHighlighterUtils,
type PdfSelection,
type ScaledPosition,
} 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/pdf_viewer.css";
import "./highlight-styles.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
import { runScrollToHighlightJob } from "./scroll-job";
export { selectorsFromPdfCapture };
/**
* Inverse of `selectorsFromPdfCapture`: build a viewer-renderable
* `Highlight` from stored selectors. The spike's reload path leans on
* `PdfRectSelector` since it carries page + page-relative rects directly.
* T04 will own the production resolver and add the text-only paths.
*/
function highlightFromSelectors(
id: string,
text: string,
selectors: readonly Selector[],
): Highlight | null {
const rectSel = findPdfRectSelector(selectors);
if (!rectSel) return null;
const boundingRect = unionRect(rectSel.rects);
if (!boundingRect) return null;
const scaledRects = rectSel.rects.map((r) => toScaled(r, rectSel.page));
return {
id,
type: "text",
content: { text },
position: {
boundingRect: toScaled(boundingRect, rectSel.page),
rects: scaledRects,
} satisfies ScaledPosition,
};
}
/**
* Convert the adapter's `NormalizedRect` (page-relative 0..1) to the
* `Scaled` shape react-pdf-highlighter-plus expects (also normalized 0..1
* via width/height). We use a unit page-space of 1×1 — the library
* computes pixel coords from `pageNumber` and the renderer's actual page
* dimensions.
*/
function toScaled(r: NormalizedRect, page: number) {
return {
x1: r.x,
y1: r.y,
x2: r.x + r.width,
y2: r.y + r.height,
width: 1,
height: 1,
pageNumber: page,
};
}
/** PdfSelection → our domain-neutral `PdfSelectionCapture`. */
function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
const page = sel.position.boundingRect.pageNumber;
const rects = sel.position.rects.map<NormalizedRect>((r) => ({
x: r.x1 / r.width,
y: r.y1 / r.height,
width: (r.x2 - r.x1) / r.width,
height: (r.y2 - r.y1) / r.height,
}));
const br = sel.position.boundingRect;
const boundingRect: NormalizedRect = {
x: br.x1 / br.width,
y: br.y1 / br.height,
width: (br.x2 - br.x1) / br.width,
height: (br.y2 - br.y1) / br.height,
};
return {
kind: "pdf",
text: sel.content.text ?? "",
page,
rects,
boundingRect,
};
}
const ActiveAnnotationContext = createContext<string | null | undefined>(
undefined,
);
const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>(
undefined,
);
/**
* Stable highlight row — component type never changes so PdfHighlighter does
* not remount highlight layers on activation changes (which disturbs scroll).
* Active/focus styling reads from context instead.
*/
function SpikeHighlightContainer(): ReactNode {
const activeAnnotationId = useContext(ActiveAnnotationContext);
const onHighlightClicked = useContext(HighlightClickContext);
const { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = activeAnnotationId === highlight.id;
return (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
e.stopPropagation();
onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
}
/**
* Resolve the rendered DOM rect for a highlight by data attribute, or
* `null` if the highlight isn't currently rendered (e.g. its page hasn't
* scrolled into view). Used by `app/forms/HighlightRectBridge` to feed
* the rect registry as kind="highlight".
*
* `display: contents` on the wrapper means it has no box of its own; we
* union the rects of its children. For TextHighlight that's typically
* one rect per line.
*/
export function getHighlightClientRects(annotationId: string): DOMRect | null {
if (typeof document === "undefined") return null;
const wrapper = document.querySelector(`[data-highlight-id="${CSS.escape(annotationId)}"]`);
if (!wrapper) return null;
const rects = wrapper.getClientRects();
if (rects.length === 0) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const r of Array.from(rects)) {
left = Math.min(left, r.left);
top = Math.min(top, r.top);
right = Math.max(right, r.right);
bottom = Math.max(bottom, r.bottom);
}
if (!isFinite(left)) return null;
return new DOMRect(left, top, right - left, bottom - top);
}
export interface PdfSpikeViewerProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
/** Previously-saved selector sets to restore on mount. */
readonly storedAnnotations: readonly StoredAnnotation[];
/** Called when the user produces a new selection. */
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
/** Annotation id to scroll to and highlight on mount, if any. */
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 {
readonly id: string;
readonly text: string;
readonly selectors: readonly Selector[];
}
/**
* The spike's React component. Renders a PDF and:
* - emits `onSelectionCaptured(capture, selectors)` on every fresh selection
* - reconstructs and renders `storedAnnotations` immediately on load
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
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 scrollStateRef = useRef({ lastCompletedKey: null as string | null });
const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors);
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;
}, [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(() => {
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
if (!requestKey || !scrollToAnnotationId) return;
if (scrollStateRef.current.lastCompletedKey === requestKey) return;
if (debugTextLayer) {
console.log("[ce] scrollToAnnotation requested", {
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 (
<div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader document={pdfLoaderDocument}>
{(pdfDocument) => (
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
<HighlightClickContext.Provider value={handleHighlightClicked}>
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlightsForViewer}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
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);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)}
</PdfLoader>
</div>
);
}
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };

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

@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type {
PdfPageTextSelector,
PdfRectSelector,
TextPositionSelector,
TextQuoteSelector,
} from "@shared/selector";
import { createSelectors } from "./create";
import type { PdfSelectionCapture } from "../types";
function repr(canonicalText: string): DocumentRepresentation {
const pageLength = canonicalText.length;
return {
id: "rep_test" as RepresentationId,
documentId: "doc_test" as DocumentId,
representationType: "pdf-text",
contentHash: "test",
canonicalText,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: pageLength, pageLength },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
}
function capture(text: string, page = 1, rectsCount = 1): PdfSelectionCapture {
return {
kind: "pdf",
text,
page,
rects: Array.from({ length: rectsCount }, (_, i) => ({
x: 0.1,
y: 0.2 + i * 0.05,
width: 0.5,
height: 0.04,
})),
boundingRect: { x: 0.1, y: 0.2, width: 0.5, height: 0.04 * rectsCount },
};
}
describe("createSelectors", () => {
const text = "The quick brown fox jumps over the lazy dog near the river bank.";
const representation = repr(text);
it("always includes a TextQuoteSelector with prefix and suffix from canonical text", () => {
const sels = createSelectors(capture("brown fox"), representation);
const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector");
expect(quote).toBeDefined();
expect(quote!.exact).toBe("brown fox");
expect(quote!.prefix).toBe("The quick ");
expect(quote!.suffix).toBe(" jumps over the lazy dog near th");
});
it("includes a TextPositionSelector pointing at the matched offset", () => {
const sels = createSelectors(capture("brown fox"), representation);
const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector");
expect(pos).toBeDefined();
expect(pos!.start).toBe(text.indexOf("brown fox"));
expect(pos!.end).toBe(text.indexOf("brown fox") + "brown fox".length);
});
it("includes a PdfRectSelector mirroring the capture's page and rects", () => {
const c = capture("brown fox", 1, 2);
const sels = createSelectors(c, representation);
const rect = sels.find((s): s is PdfRectSelector => s.type === "PdfRectSelector");
expect(rect).toBeDefined();
expect(rect!.page).toBe(1);
expect(rect!.rects).toEqual(c.rects);
});
it("includes a PdfPageTextSelector when the match falls inside the capture's page range", () => {
const sels = createSelectors(capture("brown fox"), representation);
const pageText = sels.find((s): s is PdfPageTextSelector => s.type === "PdfPageTextSelector");
expect(pageText).toBeDefined();
expect(pageText!.page).toBe(1);
expect(pageText!.start).toBe(text.indexOf("brown fox"));
});
it("omits the TextPositionSelector when the quote cannot be found in canonical text", () => {
const sels = createSelectors(capture("nonexistent phrase"), representation);
const pos = sels.find((s) => s.type === "TextPositionSelector");
expect(pos).toBeUndefined();
const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector");
expect(quote!.exact).toBe("nonexistent phrase");
expect(quote!.prefix).toBeUndefined();
expect(quote!.suffix).toBeUndefined();
});
it("clamps prefix at the start of the canonical text", () => {
const sels = createSelectors(capture("The quick"), representation);
const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!;
expect(quote.prefix).toBeUndefined();
expect(quote.suffix).toBe(" brown fox jumps over the lazy d");
});
it("clamps suffix at the end of the canonical text", () => {
const sels = createSelectors(capture("river bank."), representation);
const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!;
expect(quote.prefix).toBe("umps over the lazy dog near the ");
expect(quote.suffix).toBeUndefined();
});
it("honors a custom contextChars option", () => {
const sels = createSelectors(capture("brown fox"), representation, { contextChars: 4 });
const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!;
expect(quote.prefix).toBe("ick ");
expect(quote.suffix).toBe(" jum");
});
it("prefers the on-page match when the quote appears on multiple pages", () => {
// Two-page representation where the quote appears once per page.
const canonical = "alpha echo bravo" + "\n\n" + "charlie echo delta";
const rep: DocumentRepresentation = {
id: "rep_multi" as RepresentationId,
documentId: "doc_multi" as DocumentId,
representationType: "pdf-text",
contentHash: "h",
canonicalText: canonical,
pageMap: [
{ page: 1, width: 100, height: 100 },
{ page: 2, width: 100, height: 100 },
],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: 18, pageLength: 18 },
{ page: 2, globalStart: 18, globalEnd: canonical.length, pageLength: canonical.length - 18 },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
const sels = createSelectors(capture("echo", 2), rep);
const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector")!;
expect(pos.start).toBe(canonical.indexOf("echo", 18));
});
});

View File

@@ -0,0 +1,157 @@
/**
* Build the maximal `Selector[]` from a viewer's `SelectionCapture`.
*
* Implements the "always store all selector types that are available" rule
* from `wiki/SharedContracts.md` §3 (selector redundancy) and the create
* half of the `AnchorAdapter` contract in
* `wiki/ArchitectureOverview.md` §3.3.
*
* Output guarantee: every returned `Selector[]` includes a
* `TextQuoteSelector` (always) and adds `TextPositionSelector`,
* `PdfRectSelector`, `PdfPageTextSelector` only when the underlying data
* actually supports them. Resolvers can rely on the union being trimmed —
* a missing selector means "not available", not "skipped".
*/
import type { DocumentRepresentation } from "@shared/document";
import { normalize } from "@shared/text/normalize";
import type {
PdfPageTextSelector,
PdfRectSelector,
Selector,
TextPositionSelector,
TextQuoteSelector,
} from "@shared/selector";
import type { PdfSelectionCapture, SelectionCapture } from "../types";
/** Default characters of prefix/suffix context stored on TextQuoteSelector. */
export const DEFAULT_CONTEXT_CHARS = 32;
export interface CreateSelectorsOptions {
readonly contextChars?: number;
}
export function createSelectors(
capture: SelectionCapture,
representation: DocumentRepresentation,
options: CreateSelectorsOptions = {},
): Selector[] {
// `SelectionCapture` is a discriminated union. The DOM branch is `never`
// in MVP, so the only runtime shape is `PdfSelectionCapture`.
return createSelectorsFromPdfCapture(capture, representation, options);
}
function createSelectorsFromPdfCapture(
capture: PdfSelectionCapture,
representation: DocumentRepresentation,
options: CreateSelectorsOptions,
): Selector[] {
const contextChars = options.contextChars ?? DEFAULT_CONTEXT_CHARS;
const normalizedQuote = normalize(capture.text).text;
const out: Selector[] = [];
const canonicalText = representation.canonicalText ?? "";
const positions = canonicalText.length > 0 && normalizedQuote.length > 0
? findAllOccurrences(canonicalText, normalizedQuote)
: [];
// Locate the match that falls on the capture's page (when offsetMap is
// known); otherwise fall back to the first match. If there is no match,
// we still emit a quote-only TextQuoteSelector so the annotation is
// recoverable later if the representation is rebuilt.
const pageRange = representation.offsetMap?.find((r) => r.page === capture.page);
const matchOffset = pickMatch(positions, pageRange);
// 1. TextQuoteSelector — always included.
if (normalizedQuote.length > 0) {
const quote = matchOffset !== null
? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
: ({ type: "TextQuoteSelector", exact: normalizedQuote } satisfies TextQuoteSelector);
out.push(quote);
}
// 2. TextPositionSelector — only when we have a unique-enough match.
if (matchOffset !== null) {
const pos: TextPositionSelector = {
type: "TextPositionSelector",
start: matchOffset,
end: matchOffset + normalizedQuote.length,
};
out.push(pos);
}
// 3. PdfRectSelector — straight from the capture; viewer-coordinate truth.
if (capture.rects.length > 0) {
const rect: PdfRectSelector = {
type: "PdfRectSelector",
page: capture.page,
rects: capture.rects,
};
out.push(rect);
}
// 4. PdfPageTextSelector — when we have offsetMap and a unique-enough match
// that falls inside the capture's page range.
if (matchOffset !== null && pageRange) {
if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) {
const pageText: PdfPageTextSelector = {
type: "PdfPageTextSelector",
page: capture.page,
start: matchOffset - pageRange.globalStart,
end: matchOffset - pageRange.globalStart + normalizedQuote.length,
};
out.push(pageText);
}
}
return out;
}
function findAllOccurrences(haystack: string, needle: string): number[] {
if (needle.length === 0) return [];
const out: number[] = [];
let from = 0;
for (;;) {
const idx = haystack.indexOf(needle, from);
if (idx === -1) break;
out.push(idx);
from = idx + 1;
}
return out;
}
function pickMatch(
positions: readonly number[],
pageRange: { globalStart: number; globalEnd: number } | undefined,
): number | null {
if (positions.length === 0) return null;
if (positions.length === 1) return positions[0]!;
if (pageRange) {
const onPage = positions.find(
(p) => p >= pageRange.globalStart && p < pageRange.globalEnd,
);
if (onPage !== undefined) return onPage;
}
// Multiple matches and no page hint — return the first; resolve.ts will
// need prefix/suffix to disambiguate.
return positions[0]!;
}
function buildQuoteSelectorWithContext(
canonicalText: string,
matchOffset: number,
exact: string,
contextChars: number,
): TextQuoteSelector {
const prefixStart = Math.max(0, matchOffset - contextChars);
const suffixEnd = Math.min(canonicalText.length, matchOffset + exact.length + contextChars);
const prefix = canonicalText.slice(prefixStart, matchOffset);
const suffix = canonicalText.slice(matchOffset + exact.length, suffixEnd);
return {
type: "TextQuoteSelector",
exact,
...(prefix.length > 0 ? { prefix } : {}),
...(suffix.length > 0 ? { suffix } : {}),
};
}

View File

@@ -0,0 +1,6 @@
export {
createSelectors,
DEFAULT_CONTEXT_CHARS,
type CreateSelectorsOptions,
} from "./create";
export { resolveSelectors } from "./resolve";

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import type { DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import { resolveSelectors } from "./resolve";
function repr(canonicalText: string, pages = 1): DocumentRepresentation {
const segmentLen = pages === 1
? canonicalText.length
: Math.floor(canonicalText.length / pages);
const offsetMap = [];
for (let i = 0; i < pages; i++) {
const start = i * segmentLen;
const end = i === pages - 1 ? canonicalText.length : start + segmentLen;
offsetMap.push({ page: i + 1, globalStart: start, globalEnd: end, pageLength: end - start });
}
return {
id: "rep_test" as RepresentationId,
documentId: "doc_test" as DocumentId,
representationType: "pdf-text",
contentHash: "test",
canonicalText,
pageMap: Array.from({ length: pages }, (_, i) => ({ page: i + 1, width: 595, height: 842 })),
offsetMap,
generatedAt: "2026-05-25T00:00:00.000Z",
};
}
describe("resolveSelectors", () => {
const text = "The quick brown fox jumps over the lazy dog.";
const representation = repr(text);
const brownFoxStart = text.indexOf("brown fox");
const brownFoxEnd = brownFoxStart + "brown fox".length;
it("returns 1.0 confidence when position and quote agree exactly", () => {
const selectors: Selector[] = [
{ type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd },
{ type: "TextQuoteSelector", exact: "brown fox" },
];
const r = resolveSelectors(selectors, representation);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(1.0);
expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd });
expect(r.candidates[0]?.page).toBe(1);
expect(r.usedSelectorTypes).toEqual(["TextPositionSelector", "TextQuoteSelector"]);
});
it("falls back to quote search when position is stale, and records a warning", () => {
const selectors: Selector[] = [
{ type: "TextPositionSelector", start: 0, end: 9 }, // "The quick"
{ type: "TextQuoteSelector", exact: "brown fox" },
];
const r = resolveSelectors(selectors, representation);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.95);
expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd });
expect(r.warnings?.[0]).toMatch(/did not match/);
expect(r.usedSelectorTypes).toEqual(["TextQuoteSelector"]);
});
it("returns 0.85 for a position-only selector with no quote to verify", () => {
const selectors: Selector[] = [
{ type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd },
];
const r = resolveSelectors(selectors, representation);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.85);
});
it("returns 0.95 when only TextQuoteSelector is present and the quote is unique", () => {
const r = resolveSelectors(
[{ type: "TextQuoteSelector", exact: "brown fox" }],
representation,
);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.95);
});
it("returns 0.9 when a duplicated quote is disambiguated by prefix/suffix", () => {
const dup = "alpha echo bravo charlie echo delta";
const r = resolveSelectors(
[{ type: "TextQuoteSelector", exact: "echo", prefix: "charlie ", suffix: " delta" }],
repr(dup),
);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.9);
expect(r.candidates[0]?.textPosition?.start).toBe(dup.indexOf("echo", 10));
});
it("returns ambiguous when a duplicated quote cannot be disambiguated", () => {
const dup = "echo and echo";
const r = resolveSelectors(
[{ type: "TextQuoteSelector", exact: "echo" }],
repr(dup),
);
expect(r.status).toBe("ambiguous");
expect(r.confidence).toBe(0.5);
});
it("falls back to PdfPageTextSelector via the OffsetMap", () => {
// Single page, "brown fox" at offset 10..19.
const r = resolveSelectors(
[{ type: "PdfPageTextSelector", page: 1, start: brownFoxStart, end: brownFoxEnd }],
representation,
);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.8);
expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd });
expect(r.candidates[0]?.page).toBe(1);
});
it("falls back to PdfRectSelector with page+rects only at 0.7 confidence", () => {
const r = resolveSelectors(
[{
type: "PdfRectSelector",
page: 2,
rects: [{ x: 0.1, y: 0.2, width: 0.3, height: 0.04 }],
}],
repr(text, 1),
);
expect(r.status).toBe("resolved");
expect(r.confidence).toBe(0.7);
expect(r.candidates[0]?.page).toBe(2);
expect(r.candidates[0]?.textPosition).toBeUndefined();
expect(r.candidates[0]?.rects).toHaveLength(1);
});
it("returns unresolved when nothing matches", () => {
const r = resolveSelectors(
[{ type: "TextQuoteSelector", exact: "missing string" }],
representation,
);
expect(r.status).toBe("unresolved");
expect(r.confidence).toBe(0);
expect(r.candidates).toEqual([]);
});
});

View File

@@ -0,0 +1,260 @@
/**
* Resolve a `Selector[]` against a `DocumentRepresentation`.
*
* Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7,
* MVP-trimmed:
*
* 1. Try `TextPositionSelector` (cheapest — direct slice).
* 2. Verify with `TextQuoteSelector` at that position.
* 3. Try `TextQuoteSelector` on its own. If multiple matches, disambiguate
* by prefix/suffix.
* 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap).
* 5. Fall back to `PdfRectSelector` for a page+rects-only target.
* 6. Return `unresolved` if nothing above succeeds.
*
* Fuzzy matching is out of scope here; a later workplan owns it.
*
* Confidence ladder (0..1):
* 1.00 — TextPosition + TextQuote agree exactly
* 0.95 — TextQuote unique match (no position to cross-check)
* 0.90 — TextQuote disambiguated by prefix/suffix
* 0.85 — TextPosition only (no quote to cross-check)
* 0.80 — PdfPageTextSelector resolved via OffsetMap
* 0.70 — PdfRectSelector only (page+rects, no text verification)
*/
import type { DocumentRepresentation } from "@shared/document";
import type {
PdfPageTextSelector,
PdfRectSelector,
Selector,
SelectorType,
TextPositionSelector,
TextQuoteSelector,
} from "@shared/selector";
import type { AnchorResolution, ResolvedAnchorTarget } from "../types";
export function resolveSelectors(
selectors: readonly Selector[],
representation: DocumentRepresentation,
): AnchorResolution {
const canonicalText = representation.canonicalText ?? "";
const offsetMap = representation.offsetMap ?? [];
const representationId = representation.id;
const byType = indexByType(selectors);
const used: SelectorType[] = [];
const warnings: string[] = [];
// 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector.
if (byType.TextPositionSelector && canonicalText.length > 0) {
const pos = byType.TextPositionSelector;
const slice = sliceSafely(canonicalText, pos.start, pos.end);
if (slice !== null) {
const quote = byType.TextQuoteSelector;
if (quote) {
if (slice === quote.exact) {
used.push("TextPositionSelector", "TextQuoteSelector");
return resolved(
{ representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
1.0,
used,
warnings,
);
}
warnings.push(
"TextPositionSelector slice did not match TextQuoteSelector.exact; falling back to quote search.",
);
} else {
// Position with no quote to verify — accept at lower confidence.
used.push("TextPositionSelector");
return resolved(
{ representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
0.85,
used,
warnings,
);
}
}
}
// 3. TextQuoteSelector on its own (or after the position fallback above).
if (byType.TextQuoteSelector && canonicalText.length > 0) {
const quoteResult = resolveByQuote(canonicalText, byType.TextQuoteSelector);
if (quoteResult) {
used.push("TextQuoteSelector");
return resolved(
{
representationId,
textPosition: { start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length },
...pageFor({ start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, offsetMap),
},
quoteResult.confidence,
used,
warnings,
quoteResult.status,
);
}
}
// 4. PdfPageTextSelector through OffsetMap.
if (byType.PdfPageTextSelector && offsetMap.length > 0) {
const pageText = byType.PdfPageTextSelector;
const range = offsetMap.find((r) => r.page === pageText.page);
if (range && pageText.start >= 0 && pageText.end <= range.pageLength && pageText.start < pageText.end) {
const globalStart = range.globalStart + pageText.start;
const globalEnd = range.globalStart + pageText.end;
used.push("PdfPageTextSelector");
return resolved(
{
representationId,
page: pageText.page,
textPosition: { start: globalStart, end: globalEnd },
},
0.8,
used,
warnings,
);
}
}
// 5. PdfRectSelector fallback (no text verification possible).
if (byType.PdfRectSelector) {
const rect = byType.PdfRectSelector;
used.push("PdfRectSelector");
return resolved(
{ representationId, page: rect.page, rects: rect.rects },
0.7,
used,
warnings,
);
}
return unresolved(warnings);
}
interface QuoteResolutionResult {
readonly offset: number;
readonly confidence: number;
readonly status: "resolved" | "ambiguous";
}
function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteResolutionResult | null {
const positions = findAllOccurrences(canonicalText, quote.exact);
if (positions.length === 0) return null;
if (positions.length === 1) {
return { offset: positions[0]!, confidence: 0.95, status: "resolved" };
}
// Multiple matches — try to disambiguate by prefix/suffix.
const filtered = positions.filter((p) => prefixSuffixMatches(canonicalText, p, quote));
if (filtered.length === 1) {
return { offset: filtered[0]!, confidence: 0.9, status: "resolved" };
}
if (filtered.length > 1) {
return { offset: filtered[0]!, confidence: 0.5, status: "ambiguous" };
}
// No prefix/suffix info or no matches with context — return ambiguous on first.
return { offset: positions[0]!, confidence: 0.5, status: "ambiguous" };
}
function prefixSuffixMatches(
canonicalText: string,
offset: number,
quote: TextQuoteSelector,
): boolean {
if (quote.prefix !== undefined) {
const prefixEnd = offset;
const prefixStart = Math.max(0, prefixEnd - quote.prefix.length);
const actualPrefix = canonicalText.slice(prefixStart, prefixEnd);
if (!actualPrefix.endsWith(quote.prefix)) return false;
}
if (quote.suffix !== undefined) {
const suffixStart = offset + quote.exact.length;
const suffixEnd = Math.min(canonicalText.length, suffixStart + quote.suffix.length);
const actualSuffix = canonicalText.slice(suffixStart, suffixEnd);
if (!actualSuffix.startsWith(quote.suffix)) return false;
}
return true;
}
interface SelectorIndex {
TextQuoteSelector?: TextQuoteSelector;
TextPositionSelector?: TextPositionSelector;
PdfRectSelector?: PdfRectSelector;
PdfPageTextSelector?: PdfPageTextSelector;
}
function indexByType(selectors: readonly Selector[]): SelectorIndex {
const idx: SelectorIndex = {};
for (const s of selectors) {
switch (s.type) {
case "TextQuoteSelector":
idx.TextQuoteSelector = s;
break;
case "TextPositionSelector":
idx.TextPositionSelector = s;
break;
case "PdfRectSelector":
idx.PdfRectSelector = s;
break;
case "PdfPageTextSelector":
idx.PdfPageTextSelector = s;
break;
}
}
return idx;
}
function sliceSafely(text: string, start: number, end: number): string | null {
if (start < 0 || end > text.length || start >= end) return null;
return text.slice(start, end);
}
function pageFor(
span: { start: number; end: number },
offsetMap: readonly { page: number; globalStart: number; globalEnd: number }[],
): { page?: number } {
if (offsetMap.length === 0) return {};
const range = offsetMap.find((r) => span.start >= r.globalStart && span.end <= r.globalEnd);
return range ? { page: range.page } : {};
}
function findAllOccurrences(haystack: string, needle: string): number[] {
if (needle.length === 0) return [];
const out: number[] = [];
let from = 0;
for (;;) {
const idx = haystack.indexOf(needle, from);
if (idx === -1) break;
out.push(idx);
from = idx + 1;
}
return out;
}
function resolved(
target: ResolvedAnchorTarget,
confidence: number,
used: readonly SelectorType[],
warnings: readonly string[],
status: "resolved" | "ambiguous" = "resolved",
): AnchorResolution {
return {
status,
confidence,
candidates: [target],
usedSelectorTypes: used,
...(warnings.length > 0 ? { warnings } : {}),
};
}
function unresolved(warnings: readonly string[]): AnchorResolution {
return {
status: "unresolved",
confidence: 0,
candidates: [],
usedSelectorTypes: [],
...(warnings.length > 0 ? { warnings } : {}),
};
}

97
src/anchor/types.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Adapter-side types owned by `evidence-anchor`.
*
* Implements the contract surface from `wiki/SharedContracts.md` §5 and the
* resolution result shape from `wiki/ArchitectureOverview.md` §3.3 / §7.
*
* Anything that mentions a concrete viewer library (pdfjs, react-pdf-highlighter-plus)
* lives *behind* this surface, never on it. `src/shared/` and `src/engine/`
* must never import this file.
*/
import type { Document, DocumentRepresentation } from "@shared/document";
import type { Selector } from "@shared/selector";
import type { AnnotationResolutionStatus } from "@shared/annotation";
import type { NormalizedRect } from "@shared/selector";
/**
* The raw selection captured from a viewer adapter — an opaque payload that
* the adapter understands. The shape is intentionally permissive: each
* concrete adapter narrows the `kind` discriminator and adds its own
* payload. The shared layer never inspects the payload directly.
*/
export type SelectionCapture =
| PdfSelectionCapture
| DomSelectionCapture;
export interface PdfSelectionCapture {
readonly kind: "pdf";
/** Verbatim selected text, before canonical normalisation. */
readonly text: string;
/** 1-indexed physical page number the selection started on. */
readonly page: number;
/** Page-relative normalized rectangles covering the selection (0..1). */
readonly rects: readonly NormalizedRect[];
/** Optional bounding rectangle (page-relative, normalized). */
readonly boundingRect?: NormalizedRect;
}
/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */
export type DomSelectionCapture = never;
/**
* A passage located inside a representation, ready to be scrolled to and
* highlighted.
*/
export interface ResolvedAnchorTarget {
readonly representationId: string;
/** 1-indexed page (PDF) or undefined for HTML/Markdown. */
readonly page?: number;
/** Page-relative normalized rectangles to highlight. */
readonly rects?: readonly NormalizedRect[];
/** Canonical-text offsets, when known. */
readonly textPosition?: { readonly start: number; readonly end: number };
}
/**
* The outcome of asking the adapter to resolve a `Selector[]`.
* Matches `wiki/ArchitectureOverview.md` §3.3.
*/
export interface AnchorResolution {
readonly status: AnnotationResolutionStatus;
/** 0..1 confidence in the best candidate. */
readonly confidence: number;
readonly candidates: readonly ResolvedAnchorTarget[];
/** Names of the selector kinds that produced a usable candidate. */
readonly usedSelectorTypes: readonly string[];
readonly warnings?: readonly string[];
}
export interface HighlightRenderOptions {
readonly color?: string;
readonly opacity?: number;
}
/**
* The format-neutral viewer adapter contract from `wiki/SharedContracts.md` §5.
*
* Concrete implementations live alongside the viewer they wrap (e.g. the
* PDF spike in `src/anchor/pdf-viewer-adapter-spike.tsx`). The shared/engine
* layers depend only on this interface.
*/
export interface DocumentViewerAdapter {
readonly mediaTypes: readonly string[];
load(document: Document, representation?: DocumentRepresentation): Promise<void>;
getCurrentSelection(): Promise<SelectionCapture | null>;
createSelectorsFromSelection(selection: SelectionCapture): Promise<Selector[]>;
resolveSelectors(selectors: readonly Selector[]): Promise<AnchorResolution>;
scrollToResolvedTarget(
target: ResolvedAnchorTarget,
opts?: { readonly center?: boolean; readonly behavior?: "auto" | "smooth" },
): Promise<void>;
renderHighlight(
target: ResolvedAnchorTarget,
opts?: HighlightRenderOptions,
): Promise<void>;
getHighlightClientRects(annotationId: string): Promise<readonly DOMRect[]>;
}

375
src/app/App.tsx Normal file
View File

@@ -0,0 +1,375 @@
/**
* App — citation-evidence demo shell (CE-WP-0005).
*
* Composition:
*
* SessionProvider (cross-session)
* └─ AppShell — owns routing + the top bar
* ├─ if no active session → CreateFirstSession (empty state)
* └─ else
* EngineProvider key={sessionId} sessionId={sessionId}
* └─ BinderProvider bus={engine.bus}
* └─ ReviewLayout | FormsApp (per `mode`)
*
* The hash is the single source of truth for `{sessionId, mode}`. The
* SessionService's active id is kept in sync with the hash via a
* useEffect inside `AppShell`. Deep links to unknown sessions redirect
* to the empty state with a toast.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BinderProvider } from "@binder/index";
import {
EngineProvider,
SessionProvider,
useActiveSession,
useActiveSessionId,
useEngine,
usePdfByteStore,
useSessionByteStoreRegistry,
useSessionService,
useSessionsHydrated,
useSessionVersion,
useSessionVersionBumper,
} from "@work/index";
import { CaptureLinkPersister } from "./forms/CaptureLinkPersister";
import { loadCaptureState } from "./forms/capture-persistence";
import { FormsApp } from "./forms/FormsApp";
import { ReviewLayout } from "./ReviewLayout";
import {
CreateFirstSession,
EMPTY_ROUTE,
exportSessionZip,
importSessionZip,
parseRoute,
navigateTo,
SessionMenu,
sessionZipFilename,
Toast,
triggerSessionDownload,
UploadDropzone,
useToast,
type AppMode,
type AppRoute,
} from "./sessions";
function readRoute(): AppRoute {
if (typeof window === "undefined") return EMPTY_ROUTE;
return parseRoute(window.location.hash);
}
function useHashRoute(): AppRoute {
const [route, setRoute] = useState<AppRoute>(() => readRoute());
useEffect(() => {
const handler = () => setRoute(readRoute());
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);
return route;
}
function AppShell() {
const route = useHashRoute();
const service = useSessionService();
const hydrated = useSessionsHydrated();
const toast = useToast();
// Guards the "unknown session id → toast + redirect" path against an
// infinite loop: `useToast.show` creates a fresh `toast` object every
// render, which would otherwise re-fire the effect.
const lastHandledSessionIdRef = useRef<string | null>(null);
// Sync hash → SessionService.setActive. Unknown session ids fall back
// to the empty state with a toast.
useEffect(() => {
if (!hydrated) return;
const key = route.sessionId ?? "";
if (lastHandledSessionIdRef.current === key) return;
lastHandledSessionIdRef.current = key;
if (route.sessionId === null) {
service.setActive(null);
return;
}
const exists = service.get(route.sessionId);
if (exists) {
service.setActive(route.sessionId);
} else {
toast.show("Session not found — opened the empty state instead", "error");
navigateTo(EMPTY_ROUTE);
}
}, [route.sessionId, service, hydrated, toast]);
if (!hydrated) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
fontFamily: "system-ui, sans-serif",
color: "#888",
}}
>
Loading
</div>
);
}
if (route.sessionId === null) {
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<EmptyTopBar />
<div style={{ flex: 1, minHeight: 0 }}>
<CreateFirstSession />
</div>
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</div>
);
}
return <ActiveAppFrame route={route} toast={toast} />;
}
function ActiveAppFrame({
route,
toast,
}: {
route: AppRoute;
toast: ReturnType<typeof useToast>;
}) {
// EngineProvider remounts whenever the session id OR the per-session
// version counter changes. Import-into-active-session bumps the version
// so the new state from storage is picked up.
const sessionId = route.sessionId!;
const version = useSessionVersion(sessionId);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
<EngineProvider key={`${sessionId}:${version}`} sessionId={sessionId}>
<ActiveTopBar route={route} showToast={toast.show} />
<div style={{ flex: 1, minHeight: 0 }}>
<SessionScopedTree mode={route.mode} />
</div>
</EngineProvider>
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</div>
);
}
function SessionScopedTree({ mode }: { mode: AppMode }) {
const engine = useEngine();
const sessionId = useActiveSessionId();
const restoredCapture = useMemo(
() => (sessionId ? loadCaptureState(sessionId) : null),
[sessionId],
);
if (!sessionId) return null;
return (
<BinderProvider
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>
);
}
function EmptyTopBar() {
const sessionService = useSessionService();
const registry = useSessionByteStoreRegistry();
const bumpVersion = useSessionVersionBumper();
const toast = useToast(); // local toast — empty state has its own
const handleImport = useCallback(async (file: File) => {
try {
const result = await importSessionZip(file, {
sessionService,
getOrCreateByteStore: registry.getOrCreateByteStore,
bumpSessionVersion: bumpVersion,
});
navigateTo({ sessionId: result.sessionId, mode: "review" });
toast.show(
result.outcome === "created"
? "Imported as a new session"
: "Merged into existing session",
"success",
);
} catch (err) {
toast.show(
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
"error",
);
}
}, [sessionService, registry, bumpVersion, toast]);
return (
<header
style={{
display: "flex",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid #ddd",
background: "#fafafa",
fontFamily: "system-ui, sans-serif",
alignItems: "center",
}}
>
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
<SessionMenu onImportZip={() => pickAndImport(handleImport)} />
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</header>
);
}
function pickAndImport(onPicked: (file: File) => void): void {
if (typeof document === "undefined") return;
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip,application/zip";
input.onchange = () => {
const file = input.files?.[0];
if (file) onPicked(file);
};
input.click();
}
function ActiveTopBar({
route,
showToast,
}: {
route: AppRoute;
showToast: (msg: string, tone?: "success" | "error" | "info") => void;
}) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const session = useActiveSession();
const sessionService = useSessionService();
const registry = useSessionByteStoreRegistry();
const bumpVersion = useSessionVersionBumper();
const handleModeChange = useCallback(
(next: AppMode) => {
if (!route.sessionId) return;
navigateTo({ sessionId: route.sessionId, mode: next });
},
[route.sessionId],
);
const handleExport = useCallback(async () => {
if (!session) return;
try {
const blob = await exportSessionZip(engine, byteStore, session);
triggerSessionDownload(blob, sessionZipFilename(session));
showToast("Session exported", "success");
} catch (err) {
showToast(
err instanceof Error ? `Export failed: ${err.message}` : "Export failed",
"error",
);
}
}, [engine, byteStore, session, showToast]);
const handleImport = useCallback(
async (file: File) => {
try {
const result = await importSessionZip(file, {
sessionService,
getOrCreateByteStore: registry.getOrCreateByteStore,
bumpSessionVersion: bumpVersion,
});
navigateTo({ sessionId: result.sessionId, mode: "review" });
const totals = result.stats;
const summary =
result.outcome === "created"
? `Imported new session — ${totals.documentsAdded} document${totals.documentsAdded === 1 ? "" : "s"}, ${totals.annotationsAdded} annotation${totals.annotationsAdded === 1 ? "" : "s"}`
: `Merged into existing — ${totals.documentsAdded} new doc${totals.documentsAdded === 1 ? "" : "s"}, ${totals.documentsDeduped} deduped`;
showToast(summary, "success");
} catch (err) {
showToast(
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
"error",
);
}
},
[sessionService, registry, bumpVersion, showToast],
);
const tabs = useMemo(
() => [
{ id: "review" as const, label: "Review" },
{ id: "forms" as const, label: "Capture" },
],
[],
);
return (
<header
style={{
display: "flex",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid #ddd",
background: "#fafafa",
fontFamily: "system-ui, sans-serif",
alignItems: "center",
}}
>
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
<SessionMenu
onExportZip={() => void handleExport()}
onImportZip={() => pickAndImport((file) => void handleImport(file))}
/>
<div style={{ display: "flex", gap: 4, marginLeft: 12 }}>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => handleModeChange(t.id)}
aria-pressed={route.mode === t.id}
style={tabStyle(route.mode === t.id)}
>
{t.label}
</button>
))}
</div>
</header>
);
}
function tabStyle(active: boolean) {
return {
padding: "4px 12px",
fontSize: 12,
border: "1px solid #ccc",
borderBottom: active ? "2px solid #0050b3" : "1px solid #ccc",
background: active ? "#e8f0ff" : "white",
cursor: "pointer" as const,
};
}
export function App() {
return (
<SessionProvider>
<AppShell />
</SessionProvider>
);
}

42
src/app/ReviewLayout.tsx Normal file
View File

@@ -0,0 +1,42 @@
/**
* Review mode — the three-pane layout from CE-WP-0002-T06.
*
* ┌────────────┬──────────────────┬────────────┐
* │ Collection │ Document Viewer │ Evidence │
* │ List │ │ Sidebar │
* └────────────┴──────────────────┴────────────┘
*
* CE-WP-0005 added an `upload` slot for the active session's upload
* dropzone, threaded in by the app composition root so this component
* stays inside the `work` boundary (which cannot import `app`).
*/
import type { ReactNode } from "react";
import {
CollectionList,
EvidenceSidebar,
ViewerShell,
useActiveSession,
} from "@work/index";
export interface ReviewLayoutProps {
readonly upload?: ReactNode;
}
export function ReviewLayout({ upload }: ReviewLayoutProps) {
const session = useActiveSession();
return (
<div
style={{
display: "flex",
height: "100%",
fontFamily: "system-ui, sans-serif",
}}
>
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
<ViewerShell />
<EvidenceSidebar />
</div>
);
}

View File

@@ -0,0 +1,131 @@
/**
* ActiveEvidenceChips — chip strip for the currently-focused field.
*
* Renders one chip per link on the active target. The chip:
* - is a focusable `<button>` so Tab/Shift-Tab cycles natively;
* - registers itself with the rect registry as `kind="evidence-card"`
* and `id=evidenceItemId` (T07's overlay will draw from these);
* - calls `setActiveEvidence(evidenceItemId, annotationId)` on focus
* so the active-state machine + viewer scroll stay in sync.
*
* Auto-activation: when the active target changes and it has links, we
* focus the first chip. That gives the user immediate evidence preview
* without an extra click.
*/
import { useEffect, useRef } from "react";
import type { EvidenceItemId } from "@shared/ids";
import {
useActiveState,
useRegisterRect,
} from "@binder/index";
export interface ActiveEvidenceChipsItem {
readonly evidenceItemId: EvidenceItemId;
readonly annotationId: import("@shared/ids").AnnotationId | null;
readonly quote: string;
readonly commentary?: string;
}
export interface ActiveEvidenceChipsProps {
readonly items: readonly ActiveEvidenceChipsItem[];
}
function Chip({
item,
isActive,
}: {
item: ActiveEvidenceChipsItem;
isActive: boolean;
}) {
const ref = useRef<HTMLButtonElement>(null);
useRegisterRect("evidence-card", item.evidenceItemId, ref);
const { setActiveEvidence } = useActiveState();
return (
<button
ref={ref}
onFocus={() => setActiveEvidence(item.evidenceItemId, item.annotationId)}
onClick={() => setActiveEvidence(item.evidenceItemId, item.annotationId)}
aria-current={isActive ? "true" : undefined}
data-active={isActive ? "true" : "false"}
data-evidence-id={item.evidenceItemId}
style={{
minWidth: 200,
maxWidth: 260,
textAlign: "left",
fontSize: 12,
padding: 6,
border: isActive ? "2px solid #0050b3" : "1px solid #aac",
background: isActive ? "#e8f0ff" : "#fffceb",
cursor: "pointer",
}}
>
<div style={{ fontStyle: "italic", marginBottom: 2 }}>
&ldquo;{item.quote.slice(0, 80)}
{item.quote.length > 80 ? "…" : ""}&rdquo;
</div>
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
</button>
);
}
export function ActiveEvidenceChips({ items }: ActiveEvidenceChipsProps) {
const { state, setActiveEvidence } = useActiveState();
const targetKey = state.activeTarget
? `${state.activeTarget.targetType}:${state.activeTarget.targetId}`
: null;
// Auto-activate the first item whenever the active target changes and
// we have something to show.
useEffect(() => {
if (!targetKey) return;
if (items.length === 0) return;
if (state.activeEvidenceItemId) return; // already active
const first = items[0]!;
setActiveEvidence(first.evidenceItemId, first.annotationId);
}, [targetKey, items, state.activeEvidenceItemId, setActiveEvidence]);
if (!state.activeTarget) return null;
if (items.length === 0) {
return (
<div
role="status"
style={{
padding: 6,
fontSize: 11,
color: "#666",
fontFamily: "system-ui, sans-serif",
}}
>
No evidence linked to this field yet.
</div>
);
}
return (
<div
role="group"
aria-label="Evidence for active field"
style={{
display: "flex",
gap: 6,
padding: 6,
borderTop: "1px dashed #ccc",
background: "#fdfdfd",
flexWrap: "wrap",
fontFamily: "system-ui, sans-serif",
}}
>
{items.map((item) => (
<Chip
key={item.evidenceItemId}
item={item}
isActive={state.activeEvidenceItemId === item.evidenceItemId}
/>
))}
</div>
);
}

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;
}

610
src/app/forms/FormsApp.tsx Normal file
View File

@@ -0,0 +1,610 @@
/**
* FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007).
*
* Layout (CE-WP-0007):
*
* ┌────────────┬─────────────────┬─────────────┐
* │ Collection │ ViewerShell │ FormPane │
* ├────────────┴─────────────────┴─────────────┤
* │ EvidenceStrip (bottom) │
* └────────────────────────────────────────────┘
*
* Linking: field must have focus; clicking evidence links directly.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EvidenceItem } from "@shared/evidence";
import type { EvidenceLink } from "@shared/evidence-link";
import type { EvidenceItemId } from "@shared/ids";
import {
Overlay,
useActiveState,
useBinder,
useRegisterRect,
} from "@binder/index";
import type { SessionId } from "@shared/ids";
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
import {
CollectionList,
ViewerShell,
useActiveDocument,
useEngine,
useEngineEventTick,
useScrollToAnnotation,
} from "@work/index";
import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer";
import { persistCapturePatch } from "./capture-persistence";
import { DEMO_SCHEMA } from "./demo-schema";
import { HighlightRectBridge } from "./HighlightRectBridge";
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<CollectionList />
<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>
<EvidenceStrip fieldLabels={fieldLabels} />
<ScrollBridge />
<HighlightRectBridge />
<Overlay />
</div>
);
}
function ScrollBridge() {
const { state } = useActiveState();
const { scrollTo } = useScrollToAnnotation();
useEffect(() => {
if (state.activeAnnotationId) {
scrollTo(state.activeAnnotationId);
}
}, [state.activeAnnotationId, scrollTo]);
return null;
}
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 { bindings } = useBinder();
const engine = useEngine();
const linkTick = useEngineEventTick("EvidenceLinkCreated");
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
const { state: activeState, setActiveEvidence } = useActiveState();
useEffect(() => {
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,
]);
const linkCounts = useMemo<Record<string, number>>(() => {
const out: Record<string, number> = {};
for (const field of schema.fields) {
out[field.id] = bindings.listEvidenceForTarget({
targetType: "form-field",
targetId: field.id,
}).length;
}
void linkTick;
void unlinkTick;
return out;
}, [schema.fields, bindings, linkTick, unlinkTick]);
const linkHints = useMemo<Record<string, string>>(() => {
const out: Record<string, string> = {};
for (const field of schema.fields) {
const links = bindings.listEvidenceForTarget({
targetType: "form-field",
targetId: field.id,
});
if (links.length === 0) continue;
const item = engine.evidence.get(links[0]!.evidenceItemId);
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 (
<main
style={{
flex: "0 0 320px",
minWidth: 320,
borderLeft: "1px solid #ddd",
overflow: "auto",
display: "flex",
flexDirection: "column",
}}
>
{document ? (
<FormRenderer
schema={schema}
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 />
)}
</main>
);
}
function EmptyHint() {
return (
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
Pick a document from the collection to start capturing evidence links.
</p>
);
}
function EvidenceStrip({
fieldLabels,
}: {
fieldLabels: ReadonlyMap<string, string>;
}) {
const engine = useEngine();
const { bindings } = useBinder();
const { document } = useActiveDocument();
const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated");
const linkTick = useEngineEventTick("EvidenceLinkCreated");
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
const { state: activeState, setActiveEvidence, clearActiveEvidence } =
useActiveState();
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 [];
void createTick;
void updateTick;
void linkTick;
void unlinkTick;
return engine.evidence.listByDocument(document.id);
}, [document, engine, createTick, updateTick, linkTick, unlinkTick]);
const items = useMemo(() => {
if (effectiveFilter !== "attached" || !activeState.activeTarget) {
return allItems;
}
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;
return (
<section
aria-label="Evidence list"
style={{
borderTop: "1px solid #ddd",
background: "#fafafa",
padding: 8,
display: "flex",
flexDirection: "column",
gap: 6,
flex: "0 0 auto",
minHeight: 100,
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 && (
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
{effectiveFilter === "attached"
? "No evidence linked to the active field."
: "No evidence yet. Switch to Review mode to capture a passage."}
</p>
)}
{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]
? engine.annotations.get(item.annotationIds[0])
: null;
const quote = firstAnn?.quote ?? "(no quote)";
const formLinks = links.filter((l) => l.targetType === "form-field");
return (
<div
ref={ref}
style={{
position: "relative",
minWidth: 220,
maxWidth: 280,
flexShrink: 0,
}}
>
{formLinks.length > 0 && (
<div
style={{
position: "absolute",
top: 4,
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",
}}
>
</button>
);
})}
</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,46 @@
/**
* HighlightRectBridge — wires the viewer's rendered highlight DOM into
* the binder's rect registry as `kind="highlight"`.
*
* The viewer adapter exposes `getHighlightClientRects(annotationId)`
* (CE-WP-0003-T07) which returns the live bounding rect of a highlight
* by data attribute. We register a lazy callback that re-runs that
* lookup on every `rect-changed` event from the scroll/resize pump, so
* even as the user scrolls, the registered rect tracks the visible
* position.
*
* Lives in app/ because it spans:
* - binder (rect registry)
* - work (active document, scroll bridge)
* - anchor (the DOM-query helper)
*
* If the active annotation isn't currently rendered (its page is off
* screen, or no highlight matched), the callback returns null and the
* overlay omits the card→highlight curve until it becomes visible.
*/
import { useEffect } from "react";
import { getHighlightClientRects } from "@anchor/index";
import {
useActiveState,
useRectRegistryContext,
} from "@binder/index";
export function HighlightRectBridge() {
const { state } = useActiveState();
const { registry } = useRectRegistryContext();
useEffect(() => {
const annotationId = state.activeAnnotationId;
if (!annotationId) return;
const unregister = registry.register(
"highlight",
annotationId,
() => getHighlightClientRects(annotationId),
);
return unregister;
}, [state.activeAnnotationId, registry]);
return null;
}

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

@@ -0,0 +1,29 @@
/**
* Demo form schema for CE-WP-0003 (the form-binding slice).
*
* Deliberately minimal: text, textarea, date. JSON Schema is **not** used
* here — that's deferred to a later ADR. The MVP form's only job is to
* render a handful of fields and accept evidence links so the visual-guide
* round-trip can be exercised end-to-end.
*/
export type FormFieldSchema =
| { readonly type: "text"; readonly id: string; readonly label: string }
| { readonly type: "textarea"; readonly id: string; readonly label: string }
| { readonly type: "date"; readonly id: string; readonly label: string };
export interface FormSchema {
readonly id: string;
readonly title: string;
readonly fields: readonly FormFieldSchema[];
}
export const DEMO_SCHEMA: FormSchema = {
id: "demo-form",
title: "Demo evidence-backed form",
fields: [
{ type: "textarea", id: "summary", label: "Summary of the matter" },
{ type: "date", id: "deadline", label: "Key deadline" },
{ type: "text", id: "amount", label: "Disputed amount" },
],
};

View File

@@ -1 +1 @@
export {};
export { App } from "./App";

21
src/app/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from "react";
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";
// 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");
if (!container) throw new Error("#root not found");
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
/**
* BinderProvider — composition root for the binder subsystem.
*
* Wires the four binder concerns (rect registry, binding service, link
* repo, active state machine) into one provider so a single mount inside
* the EngineProvider gives every binder consumer (FormRenderer, evidence
* picker, SVG overlay) what it needs.
*
* The provider is split out from the engine because in a future
* subsystem-extraction these will live in separate packages — the engine
* will publish only the event bus and the engine services, while
* `evidence-binder` will export this provider.
*/
import {
createContext,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import type { EvidenceLink } from "@shared/evidence-link";
import type { EventBus } from "@engine/events";
import {
ActiveStateProvider,
useActiveState,
} from "./state/active";
import {
createInMemoryLinkRepo,
type EvidenceLinkRepository,
} from "./repos/in-memory-links";
import {
createBindingService,
type BindingService,
} from "./services/bindings";
import {
RectRegistryProvider,
createRectRegistryContextValue,
type RectRegistryContextValue,
} from "./visual-guide/react-hooks";
export interface BinderServices {
readonly links: EvidenceLinkRepository;
readonly bindings: BindingService;
readonly rect: RectRegistryContextValue;
}
const BinderServicesContext = createContext<BinderServices | null>(null);
export function useBinder(): BinderServices {
const ctx = useContext(BinderServicesContext);
if (!ctx) throw new Error("useBinder: missing <BinderProvider />");
return ctx;
}
export interface BinderProviderProps {
readonly children: ReactNode;
/**
* The engine's event bus, threaded in by the composition root so the
* binder can emit §4 events without importing work/EngineContext
* (work cannot be a dependency of binder — see DependencyMap §2).
*/
readonly bus: EventBus;
/**
* Tests can inject a pre-built service set; production constructs a
* fresh one. The rect registry is *always* fresh per provider mount
* because its observers attach to the current `window`.
*/
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,
initialLinks,
}: BinderProviderProps) {
const built = useMemo<BinderServices>(() => {
const links = services?.links ?? createInMemoryLinkRepo();
const bindings = services?.bindings ?? createBindingService(links, bus);
const rect = createRectRegistryContextValue();
return { links, bindings, rect };
}, [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.
useEffect(() => {
return () => {
built.rect.observer.disconnect();
};
}, [built.rect]);
return (
<BinderServicesContext.Provider value={built}>
<RectRegistryProvider value={built.rect}>
<ActiveStateProvider bus={bus}>{children}</ActiveStateProvider>
</RectRegistryProvider>
</BinderServicesContext.Provider>
);
}
export { useActiveState };

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

@@ -0,0 +1,114 @@
/**
* FormRenderer (CE-WP-0003-T04) — happy-dom test covering:
* - schema → DOM (3 demo fields render with their labels)
* - each field registers with rect registry as kind="field"
* - focusing a field calls activeState.focusTarget and emits FormFieldActivated
* - typing in a field invokes onValueChange
* - linkCounts shows the chip when > 0
*/
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEventBus, type EngineEvent } from "@engine/events";
import { FormRenderer, type FormSchema } from "./FormRenderer";
import {
ActiveStateProvider,
} from "./state/active";
import {
RectRegistryProvider,
createRectRegistryContextValue,
} from "./visual-guide/react-hooks";
const SCHEMA: FormSchema = {
id: "demo",
title: "Demo form",
fields: [
{ type: "textarea", id: "summary", label: "Summary" },
{ type: "date", id: "deadline", label: "Deadline" },
{ type: "text", id: "amount", label: "Amount" },
],
};
function renderWithProviders(props: Parameters<typeof FormRenderer>[0]) {
const bus = createEventBus();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
const ctxValue = createRectRegistryContextValue();
const utils = render(
<RectRegistryProvider value={ctxValue}>
<ActiveStateProvider bus={bus}>
<FormRenderer {...props} />
</ActiveStateProvider>
</RectRegistryProvider>,
);
return { ...utils, ctxValue, bus, events };
}
describe("FormRenderer (CE-WP-0003-T04)", () => {
let cleanupCtx: (() => void) | null = null;
beforeEach(() => {
cleanupCtx = null;
});
afterEach(() => {
cleanupCtx?.();
cleanup();
});
it("renders each schema field with its label", () => {
renderWithProviders({ schema: SCHEMA });
expect(screen.getByLabelText("Summary")).toBeTruthy();
expect(screen.getByLabelText("Deadline")).toBeTruthy();
expect(screen.getByLabelText("Amount")).toBeTruthy();
});
it("registers each field with the rect registry as kind=field", () => {
const { ctxValue } = renderWithProviders({ schema: SCHEMA });
cleanupCtx = () => ctxValue.observer.disconnect();
const list = ctxValue.registry.list();
expect(list).toHaveLength(3);
expect(list.every((r) => r.kind === "field")).toBe(true);
expect(list.map((r) => r.id).sort()).toEqual(["amount", "deadline", "summary"]);
});
it("focusing a field emits FormFieldActivated with the right target", async () => {
const user = userEvent.setup();
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
cleanupCtx = () => ctxValue.observer.disconnect();
await user.click(screen.getByLabelText("Summary"));
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
expect(fieldEvents).toHaveLength(1);
expect(fieldEvents[0]).toMatchObject({
target: { targetType: "form-field", targetId: "summary" },
});
});
it("typing forwards onValueChange with the field id + new value", async () => {
const user = userEvent.setup();
const changes: [string, string][] = [];
const { ctxValue } = renderWithProviders({
schema: SCHEMA,
onValueChange: (id, value) => changes.push([id, value]),
});
cleanupCtx = () => ctxValue.observer.disconnect();
await user.type(screen.getByLabelText("Amount"), "42");
expect(changes).toEqual([
["amount", "4"],
["amount", "2"],
]);
});
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
const { ctxValue } = renderWithProviders({
schema: SCHEMA,
linkCounts: { summary: 2, amount: 0 },
});
cleanupCtx = () => ctxValue.observer.disconnect();
expect(screen.queryByTestId("field-summary-chip")).not.toBeNull();
expect(screen.queryByTestId("field-amount-chip")).toBeNull();
});
});

324
src/binder/FormRenderer.tsx Normal file
View File

@@ -0,0 +1,324 @@
/**
* FormRenderer — renders a FormSchema as a small evidence-backed form.
*
* Each field registers itself with the rect registry under
* `kind="field"` and the field's `id`, so the SVG visual guide (T07) can
* draw curves from the active field to its linked evidence card and on
* to the source highlight.
*
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
*/
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
import type { EvidenceTarget } from "@shared/evidence-link";
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
import { useActiveState, type ActiveState } from "./state/active";
import { useRegisterRect } from "./visual-guide/react-hooks";
function isFieldActive(state: ActiveState, fieldId: string): boolean {
return (
state.activeTarget?.targetType === "form-field" &&
state.activeTarget?.targetId === fieldId
);
}
export interface FormFieldSchema {
readonly type: "text" | "textarea" | "date";
readonly id: string;
readonly label: string;
}
export interface FormSchema {
readonly id: string;
readonly title: string;
readonly fields: readonly FormFieldSchema[];
}
export interface FieldDefinitionPatch {
readonly label: string;
readonly type: FieldType;
}
export interface FormRendererProps {
readonly schema: FormSchema;
readonly values?: Readonly<Record<string, string>>;
readonly onValueChange?: (fieldId: string, value: string) => void;
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({
field,
value,
linkCount,
linkHint,
isActive,
isEditing,
editLabel,
editType,
onChange,
onFocus,
onBeginEdit,
onChangeEditLabel,
onChangeEditType,
onSaveEdit,
onCancelEdit,
}: {
field: FormFieldSchema;
value: string;
linkCount: number;
linkHint?: string;
isActive: boolean;
isEditing: boolean;
editLabel: string;
editType: FieldType;
onChange: (next: string) => void;
onFocus: () => void;
onBeginEdit: () => void;
onChangeEditLabel: (next: string) => void;
onChangeEditType: (next: FieldType) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
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 = {
id: `field-${field.id}`,
value,
onFocus,
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(e.target.value),
style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 },
};
return (
<div
ref={ref}
data-field-id={field.id}
data-link-count={String(linkCount)}
aria-current={isActive ? "true" : undefined}
style={{
position: "relative",
marginBottom: 12,
fontFamily: "system-ui, sans-serif",
padding: 4,
borderRadius: 4,
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
htmlFor={sharedProps.id}
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
marginBottom: 4,
paddingRight: 28,
}}
>
{field.label}
{linkCount > 0 ? (
<span
data-testid={`field-${field.id}-chip`}
title={linkHint}
style={{
marginLeft: 8,
padding: "1px 6px",
borderRadius: 4,
background: "#e7f0ff",
color: "#0050b3",
fontSize: 11,
fontWeight: 500,
}}
>
{linkCount} evidence
</span>
) : null}
</label>
{field.type === "textarea" ? (
<textarea rows={2} {...sharedProps} />
) : (
<input type={field.type === "date" ? "date" : "text"} {...sharedProps} />
)}
</div>
);
}
export function FormRenderer({
schema,
values,
onValueChange,
linkCounts,
linkHints,
showAddFieldForm,
onRequestAddField,
onConfirmAddField,
onCancelAddField,
editingFieldId,
onBeginEditField,
onSaveFieldEdit,
onCancelFieldEdit,
}: FormRendererProps) {
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 target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
focusTarget(target);
};
const beginEdit = (field: FormFieldSchema) => {
setEditLabel(field.label);
setEditType(field.type);
onBeginEditField?.(field.id);
};
return (
<form
data-form-id={schema.id}
style={{ padding: 12 }}
onSubmit={(e) => e.preventDefault()}
>
<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}
</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) => (
<FieldRow
key={field.id}
field={field}
value={values?.[field.id] ?? ""}
linkCount={linkCounts?.[field.id] ?? 0}
{...(linkHints?.[field.id] != null
? { linkHint: linkHints[field.id] }
: {})}
isActive={isFieldActive(state, field.id)}
isEditing={editingFieldId === field.id}
editLabel={editLabel}
editType={editType}
onChange={(next) => onValueChange?.(field.id, next)}
onFocus={() => handleFocus(field.id)}
onBeginEdit={() => beginEdit(field)}
onChangeEditLabel={setEditLabel}
onChangeEditType={setEditType}
onSaveEdit={() =>
onSaveFieldEdit?.(field.id, {
label: editLabel.trim(),
type: editType,
})
}
onCancelEdit={() => onCancelFieldEdit?.()}
/>
))}
</form>
);
}

View File

@@ -1 +1,12 @@
export {};
export * from "./repos";
export * from "./services";
export * from "./state";
export * from "./visual-guide";
export { FormRenderer } from "./FormRenderer";
export type {
FormFieldSchema,
FormRendererProps,
FormSchema,
} from "./FormRenderer";
export { BinderProvider, useBinder } from "./BinderProvider";
export type { BinderServices, BinderProviderProps } from "./BinderProvider";

Binary file not shown.

View File

@@ -0,0 +1 @@
export * from "./in-memory-links";

View File

@@ -0,0 +1,180 @@
/**
* Binding service + in-memory link repo tests.
*
* Exercises every public surface plus the §4 events the service emits.
*/
import { describe, expect, it } from "vitest";
import type {
EvidenceLink,
EvidenceTarget,
} from "@shared/evidence-link";
import type {
EvidenceItemId,
EvidenceLinkId,
} from "@shared/ids";
import { createEventBus } from "@engine/events";
import type { EngineEvent } from "@engine/events";
import { createInMemoryLinkRepo } from "../repos/in-memory-links";
import { createBindingService } from "./bindings";
function makeFixture() {
const bus = createEventBus();
const repo = createInMemoryLinkRepo();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
let counter = 0;
const now = () => `2026-05-25T00:00:0${counter++}.000Z`;
const service = createBindingService(repo, bus, now);
return { bus, repo, events, service };
}
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
const EV1 = "ev_test_one" as EvidenceItemId;
const EV2 = "ev_test_two" as EvidenceItemId;
describe("createBindingService", () => {
it("linkEvidenceToTarget creates a link, emits EvidenceLinkCreated, and persists it", () => {
const { service, repo, events } = makeFixture();
const link = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
});
expect(link.evidenceItemId).toBe(EV1);
expect(link.targetType).toBe("form-field");
expect(link.targetId).toBe("summary");
expect(link.relation).toBe("supports");
expect(link.status).toBe("candidate");
expect(link.createdAt).toBe(link.updatedAt);
expect(repo.get(link.id)).toEqual(link);
const created = events.filter((e) => e.type === "EvidenceLinkCreated");
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ linkId: link.id, link });
});
it("honours explicit relation/status/confidence", () => {
const { service } = makeFixture();
const link = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
relation: "contradicts",
status: "conflicting",
confidence: 0.42,
createdBy: "tegwick",
});
expect(link.relation).toBe("contradicts");
expect(link.status).toBe("conflicting");
expect(link.confidence).toBe(0.42);
expect(link.createdBy).toBe("tegwick");
});
it("listEvidenceForTarget returns only links for the requested target", () => {
const { service } = makeFixture();
const a1 = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_B });
const a2 = service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
const linksForA = service.listEvidenceForTarget(FIELD_A);
expect(linksForA.map((l) => l.id).sort()).toEqual([a1.id, a2.id].sort());
});
it("listTargetsForEvidence returns all targets an evidence item is linked to", () => {
const { service } = makeFixture();
const a = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
const b = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_B });
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
const targets = service.listTargetsForEvidence(EV1);
expect(targets.map((l) => l.id).sort()).toEqual([a.id, b.id].sort());
});
it("unlinkEvidence removes the link and reports success/failure", () => {
const { service } = makeFixture();
const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
expect(service.unlinkEvidence(link.id)).toBe(true);
expect(service.getLink(link.id)).toBeNull();
expect(service.unlinkEvidence(link.id)).toBe(false);
expect(service.unlinkEvidence("evlink_unknown" as EvidenceLinkId)).toBe(false);
});
it("updateLink merges patch, bumps updatedAt, and emits EvidenceLinkUpdated", () => {
const { service, events } = makeFixture();
const original = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
});
const updated = service.updateLink(original.id, {
status: "confirmed",
confidence: 0.9,
});
expect(updated.status).toBe("confirmed");
expect(updated.confidence).toBe(0.9);
expect(updated.relation).toBe(original.relation);
expect(updated.updatedAt).not.toBe(original.updatedAt);
const updatedEvents = events.filter((e) => e.type === "EvidenceLinkUpdated");
expect(updatedEvents).toHaveLength(1);
expect((updatedEvents[0] as Extract<EngineEvent, { type: "EvidenceLinkUpdated" }>).link).toEqual(updated);
});
it("updateLink throws on unknown id", () => {
const { service } = makeFixture();
expect(() =>
service.updateLink("evlink_unknown" as EvidenceLinkId, { status: "verified" }),
).toThrow(/unknown id/);
});
it("setActiveEvidence emits EvidenceItemActivated with source=form-field", () => {
const { service, events } = makeFixture();
service.setActiveEvidence(EV1);
const activated = events.filter((e) => e.type === "EvidenceItemActivated");
expect(activated).toHaveLength(1);
expect(activated[0]).toMatchObject({ evidenceItemId: EV1, source: "form-field" });
});
});
describe("EvidenceLinkRepository (in-memory)", () => {
it("rejects duplicate ids on create", () => {
const repo = createInMemoryLinkRepo();
const link: EvidenceLink = {
id: "evlink_x" as EvidenceLinkId,
evidenceItemId: EV1,
targetType: "form-field",
targetId: "f",
relation: "supports",
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
repo.create(link);
expect(() => repo.create(link)).toThrow(/duplicate/);
});
it("update throws on unknown id", () => {
const repo = createInMemoryLinkRepo();
const link: EvidenceLink = {
id: "evlink_unknown" as EvidenceLinkId,
evidenceItemId: EV1,
targetType: "form-field",
targetId: "f",
relation: "supports",
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
expect(() => repo.update(link)).toThrow(/unknown/);
});
});

View File

@@ -0,0 +1,118 @@
/**
* Binding service — links EvidenceItems to structured targets.
*
* Implements `wiki/ArchitectureOverview.md` §4.6 + SharedContracts §2.4
* (status enum), §2.5 (relation enum). Emits §4 events:
* `EvidenceLinkCreated`, `EvidenceLinkUpdated`, `EvidenceItemActivated`.
*
* MVP semantics:
* - `linkEvidenceToTarget` defaults `relation="supports"`, `status="candidate"`.
* - `unlinkEvidence` is hard-delete; the rejected-status path is left to
* a later ADR.
* - `setActiveEvidence` emits an `EvidenceItemActivated` event with
* `source="form-field"` so the viewer/sidebar can react.
*/
import type {
EvidenceLink,
EvidenceLinkStoredStatus,
EvidenceRelation,
EvidenceTarget,
} from "@shared/evidence-link";
import type { EvidenceItemId, EvidenceLinkId } from "@shared/ids";
import { newId } from "@shared/ids";
import type { EventBus } from "@engine/events";
import type { EvidenceLinkRepository } from "../repos/in-memory-links";
export interface LinkEvidenceToTargetInput {
readonly evidenceItemId: EvidenceItemId;
readonly target: EvidenceTarget;
readonly relation?: EvidenceRelation;
readonly status?: EvidenceLinkStoredStatus;
readonly confidence?: number;
readonly createdBy?: string;
}
export interface UpdateLinkStatusInput {
readonly status?: EvidenceLinkStoredStatus;
readonly relation?: EvidenceRelation;
readonly confidence?: number;
}
export interface BindingService {
linkEvidenceToTarget(input: LinkEvidenceToTargetInput): EvidenceLink;
unlinkEvidence(id: EvidenceLinkId): boolean;
updateLink(id: EvidenceLinkId, input: UpdateLinkStatusInput): EvidenceLink;
getLink(id: EvidenceLinkId): EvidenceLink | null;
listEvidenceForTarget(target: EvidenceTarget): readonly EvidenceLink[];
listTargetsForEvidence(evidenceItemId: EvidenceItemId): readonly EvidenceLink[];
setActiveEvidence(evidenceItemId: EvidenceItemId): void;
}
export function createBindingService(
links: EvidenceLinkRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): BindingService {
return {
linkEvidenceToTarget(input) {
const ts = now();
const link: EvidenceLink = {
id: newId("evidence-link"),
evidenceItemId: input.evidenceItemId,
targetType: input.target.targetType,
targetId: input.target.targetId,
relation: input.relation ?? "supports",
status: input.status ?? "candidate",
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = links.create(link);
bus.emit({ type: "EvidenceLinkCreated", linkId: stored.id, link: stored });
return stored;
},
unlinkEvidence(id) {
const removed = links.delete(id);
if (removed) {
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
}
return removed;
},
updateLink(id, input) {
const existing = links.get(id);
if (!existing) {
throw new Error(`BindingService.updateLink: unknown id ${id}`);
}
const next: EvidenceLink = {
...existing,
...(input.status !== undefined ? { status: input.status } : {}),
...(input.relation !== undefined ? { relation: input.relation } : {}),
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
updatedAt: now(),
};
const stored = links.update(next);
bus.emit({ type: "EvidenceLinkUpdated", linkId: stored.id, link: stored });
return stored;
},
getLink(id) {
return links.get(id);
},
listEvidenceForTarget(target) {
return links.listForTarget(target);
},
listTargetsForEvidence(evidenceItemId) {
return links.listForEvidenceItem(evidenceItemId);
},
setActiveEvidence(evidenceItemId) {
bus.emit({
type: "EvidenceItemActivated",
evidenceItemId,
source: "form-field",
});
},
};
}

View File

@@ -0,0 +1 @@
export * from "./bindings";

View File

@@ -0,0 +1,64 @@
/**
* Reducer-level tests for the active-state machine.
*
* React-level Provider/hook tests live with the integration suites.
*/
import { describe, expect, it } from "vitest";
import type { EvidenceTarget } from "@shared/evidence-link";
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
import { __test } from "./active";
const { reducer, EMPTY_ACTIVE_STATE } = __test;
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
const EV1 = "ev_one" as EvidenceItemId;
const EV2 = "ev_two" as EvidenceItemId;
const ANN1 = "ann_one" as AnnotationId;
describe("ActiveState reducer", () => {
it("focus-target sets activeTarget and clears active evidence", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const withEv = reducer(seeded, {
type: "set-active-evidence",
evidenceItemId: EV1,
annotationId: ANN1,
});
const refocused = reducer(withEv, { type: "focus-target", target: FIELD_B });
expect(refocused.activeTarget).toEqual(FIELD_B);
expect(refocused.activeEvidenceItemId).toBeNull();
expect(refocused.activeAnnotationId).toBeNull();
});
it("focus-target on the same target is a no-op (preserves identity)", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const withEv = reducer(seeded, {
type: "set-active-evidence",
evidenceItemId: EV1,
annotationId: ANN1,
});
const sameAgain = reducer(withEv, { type: "focus-target", target: { ...FIELD_A } });
expect(sameAgain).toBe(withEv);
});
it("set-active-evidence updates evidence + annotation without touching target", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const next = reducer(seeded, {
type: "set-active-evidence",
evidenceItemId: EV2,
annotationId: null,
});
expect(next.activeTarget).toEqual(FIELD_A);
expect(next.activeEvidenceItemId).toBe(EV2);
expect(next.activeAnnotationId).toBeNull();
});
it("clear returns to the empty state", () => {
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
const cleared = reducer(seeded, { type: "clear" });
expect(cleared).toEqual(EMPTY_ACTIVE_STATE);
});
});

183
src/binder/state/active.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* Active state machine + React context for the form-binding flow.
*
* Tracks the `(activeTarget, activeEvidenceItemId, activeAnnotationId)`
* triple that the SVG visual guide and the viewer adapter both depend on.
*
* Transitions:
* - `focusTarget(target)` — clears the active evidence, emits
* `FormFieldActivated`.
* - `setActiveEvidence(evidenceItemId, annotationId?)` — sets active
* evidence (and optionally the active annotation derived from it),
* emits `EvidenceItemActivated` with `source="form-field"`. The
* binding-service helper does the same; the state machine owns the
* React-facing source of truth.
* - `clear()` — drops everything back to undefined.
*
* The state itself is a small immutable record (so React equality checks
* stay simple). All mutations go through a single reducer.
*/
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
type ReactNode,
} from "react";
import type { EvidenceTarget } from "@shared/evidence-link";
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
import type { EventBus } from "@engine/events";
export interface ActiveState {
readonly activeTarget: EvidenceTarget | null;
readonly activeEvidenceItemId: EvidenceItemId | null;
readonly activeAnnotationId: AnnotationId | null;
}
export const EMPTY_ACTIVE_STATE: ActiveState = {
activeTarget: null,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
type Action =
| { type: "focus-target"; target: EvidenceTarget }
| {
type: "set-active-evidence";
evidenceItemId: EvidenceItemId;
annotationId: AnnotationId | null;
}
| { type: "clear-active-evidence" }
| { type: "clear" };
function reducer(state: ActiveState, action: Action): ActiveState {
switch (action.type) {
case "focus-target":
// Focusing a target resets the active evidence — a different field
// means a different evidence set.
if (
state.activeTarget?.targetType === action.target.targetType &&
state.activeTarget?.targetId === action.target.targetId
) {
return state;
}
return {
activeTarget: action.target,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
case "set-active-evidence":
return {
activeTarget: state.activeTarget,
activeEvidenceItemId: action.evidenceItemId,
activeAnnotationId: action.annotationId,
};
case "clear-active-evidence":
return {
activeTarget: state.activeTarget,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
case "clear":
return EMPTY_ACTIVE_STATE;
}
}
export interface ActiveStateApi {
readonly state: ActiveState;
focusTarget(target: EvidenceTarget): void;
setActiveEvidence(
evidenceItemId: EvidenceItemId,
annotationId?: AnnotationId | null,
): void;
clearActiveEvidence(): void;
clear(): void;
}
const ActiveStateContext = createContext<ActiveStateApi | null>(null);
export interface ActiveStateProviderProps {
readonly bus: EventBus;
readonly children: ReactNode;
}
/**
* React provider for the binder's active-state machine. Mounts inside the
* EngineProvider so it can wire `bus` from the engine.
*/
export function ActiveStateProvider(props: ActiveStateProviderProps) {
const [state, dispatch] = useReducer(reducer, EMPTY_ACTIVE_STATE);
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const focusTarget = useCallback(
(target: EvidenceTarget) => {
const previousTarget = stateRef.current.activeTarget;
const samePrevious =
previousTarget?.targetType === target.targetType &&
previousTarget?.targetId === target.targetId;
if (samePrevious) return;
props.bus.emit({
type: "FormFieldActivated",
target,
...(previousTarget !== null ? { previousTarget } : {}),
});
dispatch({ type: "focus-target", target });
},
[props.bus],
);
const setActiveEvidence = useCallback(
(evidenceItemId: EvidenceItemId, annotationId?: AnnotationId | null) => {
props.bus.emit({
type: "EvidenceItemActivated",
evidenceItemId,
source: "form-field",
});
dispatch({
type: "set-active-evidence",
evidenceItemId,
annotationId: annotationId ?? null,
});
},
[props.bus],
);
const clearActiveEvidence = useCallback(() => {
dispatch({ type: "clear-active-evidence" });
}, []);
const clear = useCallback(() => {
dispatch({ type: "clear" });
}, []);
const value = useMemo<ActiveStateApi>(
() => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
[state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
);
return createElement(ActiveStateContext.Provider, { value }, props.children);
}
export function useActiveState(): ActiveStateApi {
const ctx = useContext(ActiveStateContext);
if (!ctx) {
throw new Error("useActiveState must be used inside <ActiveStateProvider />");
}
return ctx;
}
/**
* Pure reducer + initial state, exported so the headless tests can verify
* transitions without spinning up React.
*/
export const __test = { reducer, EMPTY_ACTIVE_STATE };

View File

@@ -0,0 +1 @@
export * from "./active";

View File

@@ -0,0 +1,150 @@
/**
* Overlay unit test (CE-WP-0003-T07).
*
* Verifies the SVG renders the right number of paths given the active
* triple state and registered rects. Curve geometry is not asserted —
* the bezier helper is intentionally simple and changes will be caught
* by visual review, not test maintenance.
*/
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEventBus } from "@engine/events";
import { Overlay } from "./Overlay";
import { ActiveStateProvider, useActiveState } from "../state/active";
import {
RectRegistryProvider,
createRectRegistryContextValue,
type RectRegistryContextValue,
} from "./react-hooks";
import type { EvidenceTarget } from "@shared/evidence-link";
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
return {
x, y, width: w, height: h,
top: y, left: x, right: x + w, bottom: y + h,
toJSON() { return { x, y, width: w, height: h }; },
} as DOMRect;
}
const FIELD: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const EV_ID = "ev_one" as EvidenceItemId;
const ANN_ID = "ann_one" as AnnotationId;
// Tiny harness to drive the binder's active-state from outside the
// provider tree (so the test can stage state without a long click path).
function Driver({ onActive }: { onActive: (api: ReturnType<typeof useActiveState>) => void }) {
const api = useActiveState();
onActive(api);
return null;
}
describe("Overlay (CE-WP-0003-T07)", () => {
let ctx: RectRegistryContextValue;
beforeEach(() => {
ctx = createRectRegistryContextValue();
});
afterEach(() => {
ctx.observer.disconnect();
cleanup();
});
it("renders nothing when no triple is active", () => {
const bus = createEventBus();
const { container } = render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
expect(container.querySelector("svg")).toBeNull();
});
it("draws one path when only field + card rects are registered", async () => {
const bus = createEventBus();
let api: ReturnType<typeof useActiveState> | null = null;
render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Driver onActive={(a) => (api = a)} />
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
// Register the two known rects.
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
// Activate the triple. annotationId left null so no highlight is queried.
await act(async () => {
api!.focusTarget(FIELD);
api!.setActiveEvidence(EV_ID, null);
});
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
expect(svg).not.toBeNull();
expect(svg.getAttribute("data-path-count")).toBe("1");
});
it("draws two paths when field + card + highlight rects are all registered", async () => {
const bus = createEventBus();
let api: ReturnType<typeof useActiveState> | null = null;
render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Driver onActive={(a) => (api = a)} />
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
ctx.registry.register("highlight", ANN_ID, () => fakeRect(700, 400, 200, 20));
await act(async () => {
api!.focusTarget(FIELD);
api!.setActiveEvidence(EV_ID, ANN_ID);
});
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
expect(svg.getAttribute("data-path-count")).toBe("2");
expect(svg.querySelectorAll("path").length).toBe(2);
});
it("re-renders when the registry invalidates after rect changes", async () => {
const bus = createEventBus();
let api: ReturnType<typeof useActiveState> | null = null;
render(
<RectRegistryProvider value={ctx}>
<ActiveStateProvider bus={bus}>
<Driver onActive={(a) => (api = a)} />
<Overlay />
</ActiveStateProvider>
</RectRegistryProvider>,
);
ctx.registry.register("field", FIELD.targetId, () => fakeRect(0, 0, 10, 10));
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(100, 100, 10, 10));
await act(async () => {
api!.focusTarget(FIELD);
api!.setActiveEvidence(EV_ID, null);
});
const d1 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
// Mutate one of the getters' results, then invalidate.
ctx.registry.register("field", FIELD.targetId, () => fakeRect(500, 500, 10, 10));
await act(async () => {
ctx.registry.invalidate();
});
const d2 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
expect(d1).not.toBe(d2);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Visual-guide overlay — draws curves between the active triple.
*
* Subscribes to the rect registry + active-state machine and redraws a
* pair of bezier curves on every rect-change event:
*
* field ──► evidence-card ──► highlight
*
* Throttling: `attachRectChangePumps` already coalesces scroll/resize
* bursts into one `rect-changed` per animation frame. The overlay's
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
* up that single tick and React re-renders once per frame.
*
* Active-only: only the currently active triple is drawn. If any leg's
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
* the active annotation yet), that leg is omitted but the other one
* still renders.
*
* MVP-sufficient. Future polish: easing the curve direction by source
* type, animating the transition between active states, dimming
* non-active rects rather than hiding them.
*/
import { useMemo } from "react";
import { useActiveState } from "../state/active";
import {
useRectRegistryContext,
useRectRegistryVersion,
} from "./react-hooks";
function rectCenter(rect: DOMRect): { x: number; y: number } {
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
* horizontally between them. The horizontal-bulge style is right for a
* left-pane→centre-pane→right-pane layout; vertical-bulge can be added
* later when we have a layout that needs it.
*/
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
const dx = b.x - a.x;
const cpx = a.x + dx / 2;
return `M ${a.x} ${a.y} Q ${cpx} ${a.y} ${(a.x + b.x) / 2} ${(a.y + b.y) / 2} T ${b.x} ${b.y}`;
}
export interface OverlayProps {
/** Curve stroke colour. Defaults to the engine's accent blue. */
readonly strokeColor?: string;
/** Curve stroke width. Defaults to 2px. */
readonly strokeWidth?: number;
/** Optional className for styling hooks; the inline styles cover layout. */
readonly className?: string;
}
export function Overlay({
strokeColor = "#999",
strokeWidth = 1,
className,
}: OverlayProps = {}) {
const { state } = useActiveState();
const { registry } = useRectRegistryContext();
const version = useRectRegistryVersion();
const paths = useMemo<readonly string[]>(() => {
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
const highlightRect = state.activeAnnotationId
? registry.getRect("highlight", state.activeAnnotationId)
: null;
const out: string[] = [];
if (fieldRect && cardRect) {
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
}
if (cardRect && highlightRect) {
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
}
void version; // memo invalidator
return out;
}, [state, registry, version]);
if (paths.length === 0) return null;
return (
<svg
data-testid="visual-guide-overlay"
data-active-target={state.activeTarget?.targetId ?? ""}
data-active-evidence={state.activeEvidenceItemId ?? ""}
data-path-count={String(paths.length)}
className={className}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
pointerEvents: "none",
zIndex: 9999,
}}
>
{paths.map((d, i) => (
<path
key={i}
d={d}
stroke={strokeColor}
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
/>
))}
</svg>
);
}

View File

@@ -0,0 +1,118 @@
/**
* Browser-level rect-change pumps.
*
* The rect registry holds `getRect` callbacks but doesn't observe the DOM
* itself. This module wires the four global change sources from
* `wiki/SharedContracts.md` §7 ("scroll, resize, focus, and
* active-evidence change") into a single `registry.invalidate()` call.
*
* Active-evidence change is fired imperatively by the binder service when
* it calls `setActiveEvidence` — see `services/bindings.ts`.
*
* SSR-safe: every API checks `typeof window !== "undefined"` and is a
* no-op when the DOM isn't available, so tests that import this module
* under Node never crash.
*/
import type { RectRegistry } from "./rect-registry";
export interface RectChangeObserverOptions {
/**
* Throttle invalidations to a single requestAnimationFrame; otherwise a
* fast scroll event burst causes the overlay to redraw on every pixel.
* Defaults to true. Tests pass `false` for deterministic synchronous
* behaviour.
*/
readonly throttle?: boolean;
}
export interface RectChangeObserverHandle {
/**
* Begin watching a DOM element. The registry is notified of any
* scroll/resize/focus event that bubbles to the ancestor chain or fires
* on the element itself. Returns a cleanup that stops watching.
*/
observe(element: Element): () => void;
/** Tear down all observers + global listeners. */
disconnect(): void;
}
/**
* Attach scroll/resize/focus pumps to the given registry. Returns an
* observer handle so per-element ResizeObservers can be cleaned up by
* the components that registered them.
*/
export function attachRectChangePumps(
registry: RectRegistry,
options: RectChangeObserverOptions = {},
): RectChangeObserverHandle {
const throttle = options.throttle ?? true;
if (typeof window === "undefined") {
return {
observe: () => () => {},
disconnect: () => {},
};
}
let pending = false;
function invalidate() {
if (!throttle) {
registry.invalidate();
return;
}
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
registry.invalidate();
});
}
const onScroll = invalidate;
const onResize = invalidate;
const onFocusIn = invalidate;
// capture-phase scroll catches scrolling in any nested scroll container,
// not just the document — needed for the PDF viewer's inner scroller.
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
window.addEventListener("resize", onResize, { passive: true });
document.addEventListener("focusin", onFocusIn);
// One global ResizeObserver shared across observed elements is cheaper
// than per-element observers but loses the per-element resolution; we
// don't need per-element resolution because invalidations are global.
const ro: ResizeObserver | null =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(invalidate) : null;
// IntersectionObserver fires when an element moves into/out of the
// viewport — useful for the highlight which may scroll off-screen.
const io: IntersectionObserver | null =
typeof IntersectionObserver !== "undefined"
? new IntersectionObserver(invalidate, { threshold: [0, 1] })
: null;
const observedElements = new Set<Element>();
return {
observe(element) {
observedElements.add(element);
ro?.observe(element);
io?.observe(element);
return () => {
observedElements.delete(element);
ro?.unobserve(element);
io?.unobserve(element);
};
},
disconnect() {
window.removeEventListener("scroll", onScroll, { capture: true } as EventListenerOptions);
window.removeEventListener("resize", onResize);
document.removeEventListener("focusin", onFocusIn);
ro?.disconnect();
io?.disconnect();
observedElements.clear();
},
};
}

View File

@@ -0,0 +1,4 @@
export * from "./rect-registry";
export * from "./events";
export * from "./react-hooks";
export { Overlay, type OverlayProps } from "./Overlay";

View File

@@ -0,0 +1,152 @@
/**
* happy-dom-level test for the rect registry React hooks.
*
* Verifies the full §7 contract under realistic conditions:
* - useRegisterRect attaches a getRect callback bound to the
* element's getBoundingClientRect
* - mutating the element's rect produces fresh values via getRect
* - scroll/resize events on window fan out to a registry invalidate
* - useRectRegistryVersion bumps each time the registry emits
*/
// @vitest-environment happy-dom
import { act, render } from "@testing-library/react";
import { useRef } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
RectRegistryProvider,
createRectRegistryContextValue,
useRectRegistryContext,
useRectRegistryVersion,
useRegisterRect,
} from "./react-hooks";
import type { RectRegistryEvent } from "./rect-registry";
function FieldUnderTest({
id,
onVersion,
}: {
id: string;
onVersion?: (v: number) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useRegisterRect("field", id, ref);
const version = useRectRegistryVersion();
onVersion?.(version);
return <div ref={ref} data-testid={`f-${id}`} />;
}
function CtxSpy({ onCtx }: { onCtx: (registry: ReturnType<typeof useRectRegistryContext>) => void }) {
const ctx = useRectRegistryContext();
onCtx(ctx);
return null;
}
describe("useRegisterRect (happy-dom)", () => {
let ctxValue: ReturnType<typeof createRectRegistryContextValue>;
beforeEach(() => {
ctxValue = createRectRegistryContextValue();
});
afterEach(() => {
ctxValue.observer.disconnect();
});
it("registers the element's getBoundingClientRect and unregisters on unmount", () => {
const events: RectRegistryEvent[] = [];
ctxValue.registry.subscribe((e) => events.push(e));
const { unmount } = render(
<RectRegistryProvider value={ctxValue}>
<FieldUnderTest id="summary" />
</RectRegistryProvider>,
);
expect(ctxValue.registry.getRect("field", "summary")).not.toBeNull();
expect(ctxValue.registry.list()).toEqual([{ kind: "field", id: "summary" }]);
unmount();
expect(ctxValue.registry.getRect("field", "summary")).toBeNull();
expect(events.map((e) => e.type)).toContain("unregistered");
});
it("getRect reflects mutated bounding rects", () => {
let getter: () => DOMRect | null = () => null;
// Spy on the registered callback by hijacking register
const realRegister = ctxValue.registry.register;
ctxValue.registry.register = (kind, id, fn) => {
getter = fn;
return realRegister.call(ctxValue.registry, kind, id, fn);
};
render(
<RectRegistryProvider value={ctxValue}>
<FieldUnderTest id="amount" />
</RectRegistryProvider>,
);
// happy-dom returns a DOMRect with all zeros by default. Patch the
// element's getBoundingClientRect and verify the registered callback
// forwards the new rect.
const el = document.querySelector('[data-testid="f-amount"]') as HTMLDivElement;
el.getBoundingClientRect = () => ({
x: 11,
y: 22,
width: 33,
height: 44,
top: 22,
left: 11,
right: 11 + 33,
bottom: 22 + 44,
toJSON() {
return {};
},
});
const rect = getter();
expect(rect).not.toBeNull();
expect(rect!.x).toBe(11);
expect(rect!.width).toBe(33);
});
it("useRectRegistryVersion bumps on register and on invalidate", async () => {
const seen: number[] = [];
const renderResult = render(
<RectRegistryProvider value={ctxValue}>
<FieldUnderTest
id="bumpy"
onVersion={(v) => seen.push(v)}
/>
</RectRegistryProvider>,
);
// Wait one microtask for effects to flush.
await act(async () => {});
const beforeInvalidate = seen[seen.length - 1]!;
await act(async () => {
ctxValue.registry.invalidate();
});
const afterInvalidate = seen[seen.length - 1]!;
expect(afterInvalidate).toBeGreaterThan(beforeInvalidate);
renderResult.unmount();
});
it("exposes the same registry across consumers in the provider subtree", () => {
let firstCtx: ReturnType<typeof useRectRegistryContext> | undefined;
let secondCtx: ReturnType<typeof useRectRegistryContext> | undefined;
render(
<RectRegistryProvider value={ctxValue}>
<CtxSpy onCtx={(c) => (firstCtx = c)} />
<CtxSpy onCtx={(c) => (secondCtx = c)} />
</RectRegistryProvider>,
);
expect(firstCtx).toBe(secondCtx);
expect(firstCtx?.registry).toBe(ctxValue.registry);
});
});

View File

@@ -0,0 +1,98 @@
/**
* React hooks for the rect registry.
*
* Components mount, get a ref to a DOM node, and ask the registry to
* track it via `useRegisterRect(kind, id, ref)`. Unmount/ref-change
* unregisters automatically.
*
* The registry itself lives behind a React context so multiple subtrees
* can share one registry (the overlay sees what every renderer publishes).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useSyncExternalStore,
type RefObject,
} from "react";
import {
createRectRegistry,
type RectKind,
type RectRegistry,
} from "./rect-registry";
import { attachRectChangePumps, type RectChangeObserverHandle } from "./events";
export interface RectRegistryContextValue {
readonly registry: RectRegistry;
readonly observer: RectChangeObserverHandle;
}
const RectRegistryContext = createContext<RectRegistryContextValue | null>(null);
/**
* Create an isolated registry + change pump pair for tests or app
* composition roots that wire their own provider.
*/
export function createRectRegistryContextValue(): RectRegistryContextValue {
const registry = createRectRegistry();
const observer = attachRectChangePumps(registry);
return { registry, observer };
}
export function useRectRegistryContext(): RectRegistryContextValue {
const ctx = useContext(RectRegistryContext);
if (!ctx) {
throw new Error(
"useRectRegistryContext must be used inside <RectRegistryProvider />",
);
}
return ctx;
}
export const RectRegistryProvider = RectRegistryContext.Provider;
/**
* Register a DOM ref's bounding rect with the registry.
*
* Re-runs when `kind`/`id`/`ref.current` change. The observer also starts
* watching the element for scroll/resize so the overlay can re-query
* without polling.
*/
export function useRegisterRect(
kind: RectKind,
id: string,
ref: RefObject<Element | null>,
): void {
const { registry, observer } = useRectRegistryContext();
useEffect(() => {
const el = ref.current;
if (!el) return;
const unregister = registry.register(kind, id, () => el.getBoundingClientRect());
const unobserve = observer.observe(el);
return () => {
unobserve();
unregister();
};
}, [kind, id, ref, registry, observer]);
}
/**
* Subscribe to registry change events from inside React. Returns a
* monotonically-increasing version number that bumps on every event, so
* `useMemo`/`useEffect` deps can include it to re-derive cached values.
*
* Implementation: leans on `registry.getVersion()` for the snapshot so
* `useSyncExternalStore` doesn't accumulate per-render subscribers.
*/
export function useRectRegistryVersion(): number {
const { registry } = useRectRegistryContext();
const subscribe = useCallback(
(callback: () => void) => registry.subscribe(callback),
[registry],
);
const getSnapshot = useCallback(() => registry.getVersion(), [registry]);
return useSyncExternalStore(subscribe, getSnapshot, () => 0);
}

View File

@@ -0,0 +1,151 @@
/**
* Rect registry unit tests — exercise every public surface plus the
* §7-contract guarantees:
* - register/unregister fire subscriber events
* - getRect returns the live result of the registered callback
* - invalidate fires a global `rect-changed` event
* - version bumps on every emit
* - re-registering the same (kind,id) supersedes the prior callback;
* the stale unregister cleanup does not delete the new entry.
*/
import { describe, expect, it } from "vitest";
import {
createRectRegistry,
type RectRegistryEvent,
} from "./rect-registry";
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
// happy-dom/jsdom isn't loaded for this test — synth a DOMRect-shaped
// object. The registry contract only reads these properties.
return {
x,
y,
width: w,
height: h,
top: y,
left: x,
right: x + w,
bottom: y + h,
toJSON() {
return { x, y, width: w, height: h };
},
} as DOMRect;
}
describe("createRectRegistry", () => {
it("returns null for unknown rects", () => {
const r = createRectRegistry();
expect(r.getRect("field", "missing")).toBeNull();
});
it("register/getRect roundtrip", () => {
const r = createRectRegistry();
r.register("field", "f1", () => fakeRect(1, 2, 3, 4));
const rect = r.getRect("field", "f1");
expect(rect).not.toBeNull();
expect(rect!.x).toBe(1);
expect(rect!.width).toBe(3);
});
it("getRect reflects live callback results", () => {
const r = createRectRegistry();
let xPos = 10;
r.register("highlight", "h1", () => fakeRect(xPos, 0, 5, 5));
expect(r.getRect("highlight", "h1")!.x).toBe(10);
xPos = 200;
expect(r.getRect("highlight", "h1")!.x).toBe(200);
});
it("returns null when the callback throws", () => {
const r = createRectRegistry();
r.register("field", "boom", () => {
throw new Error("nope");
});
expect(r.getRect("field", "boom")).toBeNull();
});
it("emits registered + unregistered events", () => {
const r = createRectRegistry();
const events: RectRegistryEvent[] = [];
r.subscribe((e) => events.push(e));
const unregister = r.register("evidence-card", "ev1", () => fakeRect(0, 0, 1, 1));
unregister();
expect(events).toEqual([
{ type: "registered", kind: "evidence-card", id: "ev1" },
{ type: "unregistered", kind: "evidence-card", id: "ev1" },
]);
});
it("invalidate emits a global rect-changed event and bumps version", () => {
const r = createRectRegistry();
const events: RectRegistryEvent[] = [];
r.subscribe((e) => events.push(e));
const before = r.getVersion();
r.invalidate();
expect(events).toEqual([{ type: "rect-changed" }]);
expect(r.getVersion()).toBe(before + 1);
});
it("re-registering the same (kind,id) supersedes; stale cleanup is a no-op", () => {
const r = createRectRegistry();
const events: RectRegistryEvent[] = [];
r.subscribe((e) => events.push(e));
const firstGetRect = () => fakeRect(1, 1, 1, 1);
const secondGetRect = () => fakeRect(9, 9, 9, 9);
const cleanup1 = r.register("highlight", "x", firstGetRect);
r.register("highlight", "x", secondGetRect); // supersede
// The stale cleanup must not remove the new registration.
cleanup1();
expect(r.getRect("highlight", "x")!.x).toBe(9);
// Two `registered` events, no `unregistered` event — the second
// register overwrote without an explicit unregister, and the stale
// cleanup detected the (kind,id) holds a different callback.
expect(events.filter((e) => e.type === "unregistered")).toHaveLength(0);
expect(events.filter((e) => e.type === "registered")).toHaveLength(2);
});
it("subscribe returns an unsubscribe that detaches the listener", () => {
const r = createRectRegistry();
let count = 0;
const off = r.subscribe(() => count++);
r.invalidate();
off();
r.invalidate();
expect(count).toBe(1);
});
it("listener errors do not break sibling listeners", () => {
const r = createRectRegistry();
let okCount = 0;
r.subscribe(() => {
throw new Error("boom");
});
r.subscribe(() => {
okCount++;
});
r.invalidate();
expect(okCount).toBe(1);
});
it("list enumerates current registrations", () => {
const r = createRectRegistry();
r.register("field", "f1", () => null);
r.register("evidence-card", "ev1", () => null);
r.register("highlight", "h1", () => null);
const list = r.list();
expect(list).toHaveLength(3);
expect(list).toEqual(
expect.arrayContaining([
{ kind: "field", id: "f1" },
{ kind: "evidence-card", id: "ev1" },
{ kind: "highlight", id: "h1" },
]),
);
});
});

Binary file not shown.

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 +0,0 @@
export {};

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 +0,0 @@
export {};

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

@@ -1 +1,23 @@
export {};
export {
ingestPdf,
type IngestPdfInput,
type IngestPdfOptions,
type IngestPdfResult,
} from "./pdf/ingest";
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
export { fingerprintBytes } from "./pdf/fingerprint";
export {
createPdfByteStore,
type CreatePdfByteStoreOptions,
type PdfByteRecord,
type PdfByteStore,
} from "./pdf/byte-store";
export {
ingestPdfFromFile,
type IngestPdfFromFileOptions,
} from "./pdf/upload";
export {
isEphemeralBlobUri,
resolvePdfViewerUrl,
documentHasUploadedBytes,
} from "./pdf/viewer-url";

View File

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

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