generated from coulomb/repo-seed
Compare commits
39 Commits
2f25f99cae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 943eef490e | |||
| 80b4284836 | |||
| 3591554874 | |||
| 85a562b2a1 | |||
| dd2f2115bd | |||
| bb911eef37 | |||
| 6d2a12fcdb | |||
| ac35cbf476 | |||
| 6b97c0d693 | |||
| 7e1b9e5967 | |||
| b28feaad42 | |||
| ba34ba868f | |||
| 50c29da4b1 | |||
| 305a20d1d1 | |||
| fc6b91ccb0 | |||
| 48a53df9fc | |||
| 2fd085b65e | |||
| d25b01f6d5 | |||
| 3f5b0a1734 | |||
| 9c77d104cf | |||
| cfa4e9f790 | |||
| 88c7d97f54 | |||
| 7463466845 | |||
| 8082aaf7ec | |||
| c000ce6f73 | |||
| 3834f5c209 | |||
| bef2725fdd | |||
| f42b4ec87c | |||
| 430c0e124c | |||
| f0af8887d1 | |||
| 0638c441c4 | |||
| 67bcc2423c | |||
| d5474a1bd9 | |||
| a5f5c7d8a8 | |||
| 779ae0d317 | |||
| 8632f7b04a | |||
| 8607c252c4 | |||
| d54daf2e61 | |||
| 2a7b05c190 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal 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.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal 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
|
||||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal 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`
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal 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 1–3 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 -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal 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/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal 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
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal 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.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal 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)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal 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
18
.custodian-brief.md
Normal 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
26
.repo-classification.yaml
Normal 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
219
AGENTS.md
Normal 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
12
CLAUDE.md
Normal 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
27
Makefile
Normal 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
|
||||||
18
README.md
18
README.md
@@ -4,9 +4,9 @@ A document-centered evidence workspace for capturing, managing, presenting,
|
|||||||
and re-opening citations. The umbrella over the six-package design described
|
and re-opening citations. The umbrella over the six-package design described
|
||||||
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
|
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
|
||||||
|
|
||||||
During the MVP all code lives here under `src/` (see "Repository layout"
|
Shared types and engine services live in the extracted
|
||||||
below). Sister repos hold INTENT only — code migrates outward when each
|
[`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`).
|
||||||
subsystem stabilises.
|
Remaining partitions stay under `src/` until each subsystem extracts.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md.
|
|||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
|
Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep).
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
shared/ # vocabulary, types, pure helpers → becomes part of citation-engine
|
|
||||||
engine/ # services, repositories, event bus → becomes part of citation-engine
|
|
||||||
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
|
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
|
||||||
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
|
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
|
||||||
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
|
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
|
||||||
@@ -41,9 +41,9 @@ repo is intended to be a `git mv` plus a `package.json` cut — nothing more.
|
|||||||
|
|
||||||
## Sister repos
|
## Sister repos
|
||||||
|
|
||||||
Peers under `~/`; each holds INTENT.md only during MVP:
|
Peers under `~/`:
|
||||||
|
|
||||||
- [`~/citation-engine`](../citation-engine/) — shared model + engine services
|
- [`~/citation-engine`](../citation-engine/) — **extracted** shared model + engine (`@citation-evidence/engine`)
|
||||||
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
|
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
|
||||||
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
|
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
|
||||||
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
|
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
|
||||||
@@ -54,6 +54,8 @@ Peers under `~/`; each holds INTENT.md only during MVP:
|
|||||||
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
|
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# citation-engine must be checked out next to this repo (../citation-engine)
|
||||||
|
make check-install # diagnose layout problems before install
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev # vite dev server (once src/app/ has a real entry)
|
pnpm dev # vite dev server (once src/app/ has a real entry)
|
||||||
pnpm test # vitest one-shot
|
pnpm test # vitest one-shot
|
||||||
@@ -66,7 +68,7 @@ pnpm build # production bundle
|
|||||||
## Workplans (Ralph)
|
## Workplans (Ralph)
|
||||||
|
|
||||||
Workplans drive incremental implementation through the ralph loop. The harness
|
Workplans drive incremental implementation through the ralph loop. The harness
|
||||||
lives in `~/ralph-workplan/`; see `workplans/README.md` for the active list
|
lives in `~/ralph-workplan/`; see `docs/mvp-workplans-index.md` for the active list
|
||||||
and ordering.
|
and ordering.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
137
SCOPE.md
Normal file
137
SCOPE.md
Normal 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. -->
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
# ADR-0002 — Monorepo vs polyrepo for the six subsystems
|
# ADR-0002 — Monorepo vs polyrepo for the six subsystems
|
||||||
|
|
||||||
- Status: proposed
|
- Status: accepted
|
||||||
- Date: 2026-05-24
|
- Date: 2026-05-24
|
||||||
- Workplan: CE-WP-0001-T07 (stub)
|
- Decided: 2026-06-22
|
||||||
|
- Workplan: CENG-WP-0002-T01
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The umbrella-first MVP lives entirely in `citation-evidence/` under
|
The umbrella-first MVP lives entirely in `citation-evidence/` under
|
||||||
`src/{shared,engine,anchor,source,binder,work,app}/`. Each folder is named
|
`src/{anchor,source,binder,work,app}/` with shared types and engine services
|
||||||
after its eventual extracted package. At some point — driven by an external
|
in the extracted `@citation-evidence/engine` package (`citation-engine` repo).
|
||||||
consumer needing one subsystem, or by independent release cadence — code
|
|
||||||
will move out into its sister repo.
|
Each remaining folder is named after its eventual extracted package. At some
|
||||||
|
point — driven by an external consumer needing one subsystem, or by independent
|
||||||
|
release cadence — code will move out into its sister repo.
|
||||||
|
|
||||||
We need a written answer to: when that moment comes, do we (a) keep one
|
We need a written answer to: when that moment comes, do we (a) keep one
|
||||||
repository with pnpm workspaces, (b) split into six independent repos with
|
repository with pnpm workspaces, (b) split into six independent repos with
|
||||||
@@ -43,8 +46,29 @@ across the boundary.
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
(blank — to be answered before the first subsystem extraction lands.)
|
**B — six independent repos with published packages**, using **`link:` sibling
|
||||||
|
dependencies during local development** until a registry is configured.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
1. The ecosystem is already organized as six sister repos plus the umbrella;
|
||||||
|
independent repos match the documented architecture.
|
||||||
|
2. `citation-engine` extraction (`CENG-WP-0001`) and umbrella wireup
|
||||||
|
(`CE-WP-0009`) prove the `link:../citation-engine` dev workflow.
|
||||||
|
3. Publishing can be deferred — no registry is configured yet — without
|
||||||
|
blocking extraction of the remaining subsystems.
|
||||||
|
4. Option C adds tooling overhead before any external consumer exists.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
(blank)
|
- **Local dev:** sister repos sit as siblings under `~/` (or equivalent).
|
||||||
|
Consumers declare `"@citation-evidence/engine": "link:../citation-engine"`.
|
||||||
|
- **Publishing:** when a registry is chosen, bump `@citation-evidence/engine`
|
||||||
|
semver and replace `link:` with the registry version in consumer repos.
|
||||||
|
- **Contracts:** `citation-evidence/wiki/SharedContracts.md` stays authoritative;
|
||||||
|
`citation-engine/wiki/SharedContracts.md` is a conformance copy (see
|
||||||
|
`citation-engine/wiki/README.md`).
|
||||||
|
- **Versioning:** engine package semver tracks API/contract changes; umbrella
|
||||||
|
and sister repos pin or range-pin on publish.
|
||||||
|
- **CI:** each repo runs its own test/lint pipeline; cross-repo integration
|
||||||
|
tests remain in `citation-evidence` until subsystems extract fully.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ADR-0004 — PDF viewer library for the reference workspace
|
# ADR-0004 — PDF viewer library for the reference workspace
|
||||||
|
|
||||||
- Status: proposed
|
- Status: accepted (full user-flow re-verified in CE-WP-0002-T09)
|
||||||
- Date: 2026-05-24
|
- Date: 2026-05-25
|
||||||
- Workplan: CE-WP-0001-T07 (stub); validated in CE-WP-0002-T02
|
- Workplan: CE-WP-0001-T07 (stub); validated in CE-WP-0002-T02
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -40,8 +40,71 @@ failure and propose an alternative.
|
|||||||
|
|
||||||
## Decision
|
## 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
|
## 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`)".
|
||||||
|
|||||||
@@ -1,38 +1,85 @@
|
|||||||
# ADR-0005 — Persistence layer (MVP and beyond)
|
# ADR-0005 — Persistence for the MVP slice
|
||||||
|
|
||||||
- Status: proposed
|
- Status: accepted (provisional — durable storage owned by a later workplan)
|
||||||
- Date: 2026-05-24
|
- Date: 2026-05-25
|
||||||
- Workplan: CE-WP-0001-T07 (stub); MVP placeholder in CE-WP-0002-T08
|
- Workplan: CE-WP-0002-T08 (click-to-reopen requires reload-survival)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The MVP needs persistence so that "click an evidence item and have the PDF
|
CE-WP-0002 needs the click-to-reopen flow to survive a page reload (PRD
|
||||||
jump to and highlight the passage — even after a full page reload" works
|
scenario step 4 → "even after a full page reload"). The full persistence
|
||||||
(PRD §20 step 4). The acceptable MVP shortcut is `localStorage` (decided
|
design (SQLite local-first vs Postgres server-first) is too large to land
|
||||||
explicitly in CE-WP-0002-T08).
|
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
|
The engine already runs `Map`-backed in-memory repositories
|
||||||
documents, annotations, evidence items, links, and sets live in v1.0?
|
(`src/engine/repos/in-memory.ts`). To survive reloads we need *some*
|
||||||
|
persistence boundary now, without committing to the long-term store.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
- **A. Browser-local only (IndexedDB via `idb` or `dexie`)**
|
- **A. localStorage snapshot (this ADR).** The SPA serializes the entire
|
||||||
- Pros: zero infra; great for a single-user reference workspace.
|
engine state into a single JSON blob on every mutation and restores it
|
||||||
- Cons: no cross-device sync; export/import only via files.
|
on mount. No new dependencies; no schema migrations; no networking.
|
||||||
|
Per-tab only.
|
||||||
- **B. Local-first + sync server (e.g. CRDT-backed)**
|
- **B. IndexedDB-backed store.** More headroom, more API surface, async
|
||||||
- Pros: matches the long-term vision of a workspace tool; conflict-free
|
reads. Needed eventually for binary blobs (PDF bytes) but overkill for
|
||||||
multi-device.
|
the few hundred annotations the MVP produces.
|
||||||
- Cons: significant infra and CRDT design cost; out of MVP scope.
|
- **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
|
||||||
- **C. Traditional client/server with a REST or GraphQL API**
|
not keep.
|
||||||
- Pros: familiar; easy team-sharing story.
|
- **D. Server-backed persistence from day one.** Requires shipping a
|
||||||
- Cons: requires hosting; loses the local-first character.
|
backend. Premature.
|
||||||
|
|
||||||
## Decision
|
## 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
|
## 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.
|
||||||
|
|||||||
115
docs/decisions/ADR-0007-citation-card-format.md
Normal file
115
docs/decisions/ADR-0007-citation-card-format.md
Normal 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.
|
||||||
134
docs/decisions/ADR-0008-session-archive-format.md
Normal file
134
docs/decisions/ADR-0008-session-archive-format.md
Normal 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.
|
||||||
59
docs/mvp-workplans-index.md
Normal file
59
docs/mvp-workplans-index.md
Normal 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
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
// ESLint flat config (ESLint 9+).
|
// ESLint flat config (ESLint 9+).
|
||||||
// Enforces the partition dependency map in wiki/DependencyMap.md §4.
|
// Enforces the partition dependency map in wiki/DependencyMap.md §4.
|
||||||
//
|
//
|
||||||
|
// shared/ and engine/ live in the linked @citation-evidence/engine package;
|
||||||
|
// boundary rules for those partitions are enforced in citation-engine.
|
||||||
|
//
|
||||||
// Element types (folders) and allowed importers:
|
// Element types (folders) and allowed importers:
|
||||||
// shared : importable by every other element (no internal imports of its own).
|
// shared : importable by every other element (package: citation-engine).
|
||||||
// engine : imports shared.
|
// engine : imports shared (package: citation-engine).
|
||||||
// anchor : imports shared, engine.
|
// anchor : imports shared, engine.
|
||||||
// source : imports shared, engine.
|
// source : imports shared, engine.
|
||||||
// binder : imports shared, engine, anchor.
|
// binder : imports shared, engine, anchor.
|
||||||
// work : imports shared, engine, anchor, source. (NOT binder.)
|
// work : imports shared, engine, anchor, source. (NOT binder.)
|
||||||
// app : imports anything.
|
// app : imports anything.
|
||||||
//
|
|
||||||
// Path aliases (@shared/*, @engine/*, etc.) come from tsconfig.json paths and
|
|
||||||
// are resolved by eslint-import-resolver-typescript.
|
|
||||||
|
|
||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import boundaries from "eslint-plugin-boundaries";
|
import boundaries from "eslint-plugin-boundaries";
|
||||||
import importPlugin from "eslint-plugin-import";
|
import importPlugin from "eslint-plugin-import";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const engineSrc = resolve(__dirname, "../citation-engine/src");
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
@@ -41,8 +46,8 @@ export default tseslint.config(
|
|||||||
typescript: { project: "./tsconfig.json" },
|
typescript: { project: "./tsconfig.json" },
|
||||||
},
|
},
|
||||||
"boundaries/elements": [
|
"boundaries/elements": [
|
||||||
{ type: "shared", pattern: "src/shared/**" },
|
{ type: "shared", pattern: `${engineSrc}/shared/**` },
|
||||||
{ type: "engine", pattern: "src/engine/**" },
|
{ type: "engine", pattern: `${engineSrc}/engine/**` },
|
||||||
{ type: "anchor", pattern: "src/anchor/**" },
|
{ type: "anchor", pattern: "src/anchor/**" },
|
||||||
{ type: "source", pattern: "src/source/**" },
|
{ type: "source", pattern: "src/source/**" },
|
||||||
{ type: "binder", pattern: "src/binder/**" },
|
{ type: "binder", pattern: "src/binder/**" },
|
||||||
@@ -68,4 +73,4 @@ export default tseslint.config(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"_schema_version": 1,
|
"_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.",
|
"_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": [
|
"fixtures": [
|
||||||
{
|
{
|
||||||
"id": "betriebskosten-2024",
|
"id": "betriebskosten-2024",
|
||||||
"filename": "031-Kemal Güldag Betriebskosten 2024.pdf",
|
"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.",
|
"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,
|
"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,
|
"known_good_quote_page": 1,
|
||||||
"characteristics": ["german", "umlauts", "scanned", "ocr-noisy", "form", "handwritten"]
|
"characteristics": ["german", "umlauts", "scanned", "ocr-noisy", "form", "handwritten"]
|
||||||
},
|
},
|
||||||
|
|||||||
205
history/2026-06-07-ecosystem-state-assessment.md
Normal file
205
history/2026-06-07-ecosystem-state-assessment.md
Normal 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
12
index.html
Normal 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>
|
||||||
13
package.json
13
package.json
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -19,10 +19,17 @@
|
|||||||
"typecheck": "tsc -b --noEmit"
|
"typecheck": "tsc -b --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@citation-evidence/engine": "link:../citation-engine",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"pdfjs-dist": "^4.4.168",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-pdf-highlighter-plus": "^1.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/node": "^20.14.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
@@ -32,9 +39,11 @@
|
|||||||
"eslint-plugin-boundaries": "^4.2.2",
|
"eslint-plugin-boundaries": "^4.2.2",
|
||||||
"eslint-plugin-import": "^2.30.0",
|
"eslint-plugin-import": "^2.30.0",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"happy-dom": "^20.9.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
|
"vite-plugin-static-copy": "^2",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1813
pnpm-lock.yaml
generated
1813
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
12
registry/README.md
Normal file
12
registry/README.md
Normal 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`.
|
||||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
4
registry/indexes/capabilities.yaml
Normal file
4
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
version: 1
|
||||||
|
updated: '2026-06-16'
|
||||||
|
domain: helix_forge
|
||||||
|
capabilities: []
|
||||||
162
scripts/check-install.sh
Executable file
162
scripts/check-install.sh
Executable 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
|
||||||
59
src/anchor/debug-textlayer.css
Normal file
59
src/anchor/debug-textlayer.css
Normal 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;
|
||||||
|
}
|
||||||
38
src/anchor/highlight-styles.css
Normal file
38
src/anchor/highlight-styles.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
111
src/anchor/pdf-selector-math.test.ts
Normal file
111
src/anchor/pdf-selector-math.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/anchor/pdf-selector-math.ts
Normal file
79
src/anchor/pdf-selector-math.ts
Normal 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 };
|
||||||
|
}
|
||||||
424
src/anchor/pdf-viewer-adapter-spike.tsx
Normal file
424
src/anchor/pdf-viewer-adapter-spike.tsx
Normal 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 };
|
||||||
73
src/anchor/scroll-job.test.ts
Normal file
73
src/anchor/scroll-job.test.ts
Normal 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
73
src/anchor/scroll-job.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
136
src/anchor/selectors/create.test.ts
Normal file
136
src/anchor/selectors/create.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
157
src/anchor/selectors/create.ts
Normal file
157
src/anchor/selectors/create.ts
Normal 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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
6
src/anchor/selectors/index.ts
Normal file
6
src/anchor/selectors/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
createSelectors,
|
||||||
|
DEFAULT_CONTEXT_CHARS,
|
||||||
|
type CreateSelectorsOptions,
|
||||||
|
} from "./create";
|
||||||
|
export { resolveSelectors } from "./resolve";
|
||||||
137
src/anchor/selectors/resolve.test.ts
Normal file
137
src/anchor/selectors/resolve.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
260
src/anchor/selectors/resolve.ts
Normal file
260
src/anchor/selectors/resolve.ts
Normal 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
97
src/anchor/types.ts
Normal 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
375
src/app/App.tsx
Normal 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
42
src/app/ReviewLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/app/forms/ActiveEvidenceChips.tsx
Normal file
131
src/app/forms/ActiveEvidenceChips.tsx
Normal 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 }}>
|
||||||
|
“{item.quote.slice(0, 80)}
|
||||||
|
{item.quote.length > 80 ? "…" : ""}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/forms/CaptureLinkPersister.tsx
Normal file
29
src/app/forms/CaptureLinkPersister.tsx
Normal 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
610
src/app/forms/FormsApp.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“{quote.slice(0, 100)}
|
||||||
|
{quote.length > 100 ? "…" : ""}”
|
||||||
|
</div>
|
||||||
|
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/forms/HighlightRectBridge.tsx
Normal file
46
src/app/forms/HighlightRectBridge.tsx
Normal 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;
|
||||||
|
}
|
||||||
96
src/app/forms/capture-persistence.test.ts
Normal file
96
src/app/forms/capture-persistence.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/app/forms/capture-persistence.ts
Normal file
129
src/app/forms/capture-persistence.ts
Normal 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));
|
||||||
|
}
|
||||||
29
src/app/forms/demo-schema.ts
Normal file
29
src/app/forms/demo-schema.ts
Normal 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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1 +1 @@
|
|||||||
export {};
|
export { App } from "./App";
|
||||||
|
|||||||
21
src/app/main.tsx
Normal file
21
src/app/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
147
src/app/sessions/CreateFirstSession.tsx
Normal file
147
src/app/sessions/CreateFirstSession.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/sessions/SampleSessions.tsx
Normal file
125
src/app/sessions/SampleSessions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/sessions/SessionMenu.dom.test.tsx
Normal file
132
src/app/sessions/SessionMenu.dom.test.tsx
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
548
src/app/sessions/SessionMenu.tsx
Normal file
548
src/app/sessions/SessionMenu.tsx
Normal 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",
|
||||||
|
};
|
||||||
94
src/app/sessions/Toast.tsx
Normal file
94
src/app/sessions/Toast.tsx
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
93
src/app/sessions/UploadDropzone.dom.test.tsx
Normal file
93
src/app/sessions/UploadDropzone.dom.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
189
src/app/sessions/UploadDropzone.tsx
Normal file
189
src/app/sessions/UploadDropzone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/app/sessions/exportSessionZip.test.ts
Normal file
154
src/app/sessions/exportSessionZip.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
148
src/app/sessions/exportSessionZip.ts
Normal file
148
src/app/sessions/exportSessionZip.ts
Normal 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 };
|
||||||
276
src/app/sessions/importSessionZip.test.ts
Normal file
276
src/app/sessions/importSessionZip.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
314
src/app/sessions/importSessionZip.ts
Normal file
314
src/app/sessions/importSessionZip.ts
Normal 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
28
src/app/sessions/index.ts
Normal 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";
|
||||||
51
src/app/sessions/routing.test.ts
Normal file
51
src/app/sessions/routing.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
src/app/sessions/routing.ts
Normal file
61
src/app/sessions/routing.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/binder/BinderProvider.tsx
Normal file
119
src/binder/BinderProvider.tsx
Normal 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 };
|
||||||
117
src/binder/FieldDefinitionForm.tsx
Normal file
117
src/binder/FieldDefinitionForm.tsx
Normal 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",
|
||||||
|
};
|
||||||
114
src/binder/FormRenderer.dom.test.tsx
Normal file
114
src/binder/FormRenderer.dom.test.tsx
Normal 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
324
src/binder/FormRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
BIN
src/binder/repos/in-memory-links.ts
Normal file
BIN
src/binder/repos/in-memory-links.ts
Normal file
Binary file not shown.
1
src/binder/repos/index.ts
Normal file
1
src/binder/repos/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./in-memory-links";
|
||||||
180
src/binder/services/bindings.test.ts
Normal file
180
src/binder/services/bindings.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/binder/services/bindings.ts
Normal file
118
src/binder/services/bindings.ts
Normal 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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/binder/services/index.ts
Normal file
1
src/binder/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./bindings";
|
||||||
64
src/binder/state/active.test.ts
Normal file
64
src/binder/state/active.test.ts
Normal 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
183
src/binder/state/active.ts
Normal 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 };
|
||||||
1
src/binder/state/index.ts
Normal file
1
src/binder/state/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./active";
|
||||||
150
src/binder/visual-guide/Overlay.dom.test.tsx
Normal file
150
src/binder/visual-guide/Overlay.dom.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
src/binder/visual-guide/Overlay.tsx
Normal file
123
src/binder/visual-guide/Overlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/binder/visual-guide/events.ts
Normal file
118
src/binder/visual-guide/events.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/binder/visual-guide/index.ts
Normal file
4
src/binder/visual-guide/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./rect-registry";
|
||||||
|
export * from "./events";
|
||||||
|
export * from "./react-hooks";
|
||||||
|
export { Overlay, type OverlayProps } from "./Overlay";
|
||||||
152
src/binder/visual-guide/react-hooks.dom.test.tsx
Normal file
152
src/binder/visual-guide/react-hooks.dom.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/binder/visual-guide/react-hooks.ts
Normal file
98
src/binder/visual-guide/react-hooks.ts
Normal 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);
|
||||||
|
}
|
||||||
151
src/binder/visual-guide/rect-registry.test.ts
Normal file
151
src/binder/visual-guide/rect-registry.test.ts
Normal 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" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
src/binder/visual-guide/rect-registry.ts
Normal file
BIN
src/binder/visual-guide/rect-registry.ts
Normal file
Binary file not shown.
@@ -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).
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -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).
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -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 "Donaudampfschiff" line-broken with soft hyphens.
|
|
||||||
expect(normalize("Donaudampfschiff").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("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
99
src/source/pdf/byte-store.test.ts
Normal file
99
src/source/pdf/byte-store.test.ts
Normal 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
Reference in New Issue
Block a user