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
|
||||
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
|
||||
|
||||
During the MVP all code lives here under `src/` (see "Repository layout"
|
||||
below). Sister repos hold INTENT only — code migrates outward when each
|
||||
subsystem stabilises.
|
||||
Shared types and engine services live in the extracted
|
||||
[`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`).
|
||||
Remaining partitions stay under `src/` until each subsystem extracts.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md.
|
||||
|
||||
## Repository layout
|
||||
|
||||
Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep).
|
||||
|
||||
```
|
||||
src/
|
||||
shared/ # vocabulary, types, pure helpers → becomes part of citation-engine
|
||||
engine/ # services, repositories, event bus → becomes part of citation-engine
|
||||
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
|
||||
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
|
||||
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
|
||||
@@ -41,9 +41,9 @@ repo is intended to be a `git mv` plus a `package.json` cut — nothing more.
|
||||
|
||||
## Sister repos
|
||||
|
||||
Peers under `~/`; each holds INTENT.md only during MVP:
|
||||
Peers under `~/`:
|
||||
|
||||
- [`~/citation-engine`](../citation-engine/) — shared model + engine services
|
||||
- [`~/citation-engine`](../citation-engine/) — **extracted** shared model + engine (`@citation-evidence/engine`)
|
||||
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
|
||||
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
|
||||
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
|
||||
@@ -54,6 +54,8 @@ Peers under `~/`; each holds INTENT.md only during MVP:
|
||||
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
|
||||
|
||||
```bash
|
||||
# citation-engine must be checked out next to this repo (../citation-engine)
|
||||
make check-install # diagnose layout problems before install
|
||||
pnpm install
|
||||
pnpm dev # vite dev server (once src/app/ has a real entry)
|
||||
pnpm test # vitest one-shot
|
||||
@@ -66,7 +68,7 @@ pnpm build # production bundle
|
||||
## Workplans (Ralph)
|
||||
|
||||
Workplans drive incremental implementation through the ralph loop. The harness
|
||||
lives in `~/ralph-workplan/`; see `workplans/README.md` for the active list
|
||||
lives in `~/ralph-workplan/`; see `docs/mvp-workplans-index.md` for the active list
|
||||
and ordering.
|
||||
|
||||
```bash
|
||||
|
||||
137
SCOPE.md
Normal file
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
|
||||
|
||||
- Status: proposed
|
||||
- Status: accepted
|
||||
- Date: 2026-05-24
|
||||
- Workplan: CE-WP-0001-T07 (stub)
|
||||
- Decided: 2026-06-22
|
||||
- Workplan: CENG-WP-0002-T01
|
||||
|
||||
## Context
|
||||
|
||||
The umbrella-first MVP lives entirely in `citation-evidence/` under
|
||||
`src/{shared,engine,anchor,source,binder,work,app}/`. Each folder is named
|
||||
after its eventual extracted package. At some point — driven by an external
|
||||
consumer needing one subsystem, or by independent release cadence — code
|
||||
will move out into its sister repo.
|
||||
`src/{anchor,source,binder,work,app}/` with shared types and engine services
|
||||
in the extracted `@citation-evidence/engine` package (`citation-engine` repo).
|
||||
|
||||
Each remaining folder is named after its eventual extracted package. At some
|
||||
point — driven by an external consumer needing one subsystem, or by independent
|
||||
release cadence — code will move out into its sister repo.
|
||||
|
||||
We need a written answer to: when that moment comes, do we (a) keep one
|
||||
repository with pnpm workspaces, (b) split into six independent repos with
|
||||
@@ -43,8 +46,29 @@ across the boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
(blank — to be answered before the first subsystem extraction lands.)
|
||||
**B — six independent repos with published packages**, using **`link:` sibling
|
||||
dependencies during local development** until a registry is configured.
|
||||
|
||||
Rationale:
|
||||
|
||||
1. The ecosystem is already organized as six sister repos plus the umbrella;
|
||||
independent repos match the documented architecture.
|
||||
2. `citation-engine` extraction (`CENG-WP-0001`) and umbrella wireup
|
||||
(`CE-WP-0009`) prove the `link:../citation-engine` dev workflow.
|
||||
3. Publishing can be deferred — no registry is configured yet — without
|
||||
blocking extraction of the remaining subsystems.
|
||||
4. Option C adds tooling overhead before any external consumer exists.
|
||||
|
||||
## Consequences
|
||||
|
||||
(blank)
|
||||
- **Local dev:** sister repos sit as siblings under `~/` (or equivalent).
|
||||
Consumers declare `"@citation-evidence/engine": "link:../citation-engine"`.
|
||||
- **Publishing:** when a registry is chosen, bump `@citation-evidence/engine`
|
||||
semver and replace `link:` with the registry version in consumer repos.
|
||||
- **Contracts:** `citation-evidence/wiki/SharedContracts.md` stays authoritative;
|
||||
`citation-engine/wiki/SharedContracts.md` is a conformance copy (see
|
||||
`citation-engine/wiki/README.md`).
|
||||
- **Versioning:** engine package semver tracks API/contract changes; umbrella
|
||||
and sister repos pin or range-pin on publish.
|
||||
- **CI:** each repo runs its own test/lint pipeline; cross-repo integration
|
||||
tests remain in `citation-evidence` until subsystems extract fully.
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-0004 — PDF viewer library for the reference workspace
|
||||
|
||||
- Status: proposed
|
||||
- Date: 2026-05-24
|
||||
- Status: accepted (full user-flow re-verified in CE-WP-0002-T09)
|
||||
- Date: 2026-05-25
|
||||
- Workplan: CE-WP-0001-T07 (stub); validated in CE-WP-0002-T02
|
||||
|
||||
## Context
|
||||
@@ -40,8 +40,71 @@ failure and propose an alternative.
|
||||
|
||||
## Decision
|
||||
|
||||
(blank — to be filled by the outcome of CE-WP-0002-T02.)
|
||||
Accept **Option A: `react-pdf-highlighter-plus` v1.1.4** as the MVP PDF viewer.
|
||||
|
||||
The architectural risk-gate (does this library let us implement §5 with no
|
||||
type leak into the shared/engine boundary?) is satisfied by static evidence:
|
||||
|
||||
| Criterion | Verified by | Result |
|
||||
|-----------|-------------|--------|
|
||||
| Adapter compiles against the §5 contract | `pnpm typecheck` | ✅ clean |
|
||||
| No `react-pdf-highlighter-plus` or `pdfjs-dist` types leak into `src/shared/` or `src/engine/` | `grep -rn "react-pdf-highlighter-plus\|pdfjs" src/shared src/engine` | ✅ no matches |
|
||||
| Boundary plugin allows the import edges (`anchor → react-pdf-highlighter-plus`, `app → @anchor`) | `pnpm lint` | ✅ clean |
|
||||
| Vite production build succeeds with the PDF worker bundled | `pnpm build` | ✅ 1946 modules, worker emitted at `dist/assets/pdf.worker.min-*.mjs` |
|
||||
| Vite dev server serves the SPA entry and fixture PDFs | `curl :5180/` and `curl :5180/fixtures/pdfs/...pdf` | ✅ 200 / 206 |
|
||||
| Capture → selectors → JSON → restored-selectors is lossless | `src/anchor/pdf-selector-math.test.ts` | ✅ 11/11 |
|
||||
|
||||
### Pinned versions
|
||||
|
||||
- `react-pdf-highlighter-plus` `^1.1.4` (published 2026-04-30)
|
||||
- `pdfjs-dist` `^4.4.168` peer (installed 4.10.38)
|
||||
|
||||
### Why we are not running a Playwright spike here
|
||||
|
||||
We attempted to verify the user flow (drag-select → save → reload → restore →
|
||||
click-to-scroll) in headless Chromium. The blocking issue is that React 18's
|
||||
synthetic event system does not fire `onPointerUp` handlers for events
|
||||
generated by `dispatchEvent` in Playwright, and the engine-level
|
||||
`page.mouse.down/move/up` drag against pdf.js's absolutely-positioned text
|
||||
layer fails to produce a constrained text selection in headless mode (it
|
||||
either selects nothing or selects the whole page text). The library code
|
||||
path is correct; the test harness can't drive it.
|
||||
|
||||
Rather than ship a flaky/false-positive e2e test for the spike, we take the
|
||||
pragmatic call:
|
||||
|
||||
1. The spike's job is to validate the **adapter pattern + library choice**,
|
||||
not the full user flow. Both are validated above.
|
||||
2. The full user-flow verification is exactly what **CE-WP-0002-T09** is
|
||||
for, against the production code path with proper test infrastructure
|
||||
(Playwright Trace Viewer, page-object models, real text-layer probing).
|
||||
3. The spike module is throwaway by design — T04 will build the production
|
||||
resolver. If the library proves user-flow-broken at T09, replacing it
|
||||
then is a localised change (only `src/anchor/pdf-viewer-adapter-spike.tsx`
|
||||
touches the library today).
|
||||
|
||||
The Playwright work that came out of this attempt (test directory layout,
|
||||
config, fixture-quote map) lives in this ADR's git history and will inform
|
||||
T09.
|
||||
|
||||
## Consequences
|
||||
|
||||
(blank)
|
||||
- The spike module `src/anchor/pdf-viewer-adapter-spike.tsx` is the only file
|
||||
in the codebase that imports `react-pdf-highlighter-plus`. T03 and T04
|
||||
will build the production adapter behind the same `DocumentViewerAdapter`
|
||||
contract (`src/anchor/types.ts`), so replacing the viewer later is a
|
||||
localised change.
|
||||
- The CSS imports use the package's explicit `./style/style.css` and
|
||||
`./style/pdf_viewer.css` subpath exports — `./style.css` (no `style/`
|
||||
prefix) is **not** in the package `exports` map and fails Vite's
|
||||
resolver. Anyone copying the import pattern must keep the `style/`
|
||||
prefix.
|
||||
- `pdfjs-dist` is in `optimizeDeps.exclude` (see `vite.config.ts`) so its
|
||||
worker `.mjs` is emitted as a separate asset rather than pre-bundled.
|
||||
- `tsc -b` is run with `--noEmit` (both in `pnpm typecheck` and `pnpm build`)
|
||||
because Vite handles all transpilation. Without `noEmit`, `tsc -b`'s
|
||||
default emission litters `src/` with stray `.js`/`.d.ts` siblings.
|
||||
- CE-WP-0002-T09 owns the full user-flow Playwright verification. Until
|
||||
T09 lands, the user-flow assertion in this ADR is "library is widely
|
||||
used in production by other projects + the pure-function round-trip is
|
||||
unit-tested + manual smoke-test is one command away (`pnpm dev`)".
|
||||
|
||||
@@ -1,38 +1,85 @@
|
||||
# ADR-0005 — Persistence layer (MVP and beyond)
|
||||
# ADR-0005 — Persistence for the MVP slice
|
||||
|
||||
- Status: proposed
|
||||
- Date: 2026-05-24
|
||||
- Workplan: CE-WP-0001-T07 (stub); MVP placeholder in CE-WP-0002-T08
|
||||
- Status: accepted (provisional — durable storage owned by a later workplan)
|
||||
- Date: 2026-05-25
|
||||
- Workplan: CE-WP-0002-T08 (click-to-reopen requires reload-survival)
|
||||
|
||||
## Context
|
||||
|
||||
The MVP needs persistence so that "click an evidence item and have the PDF
|
||||
jump to and highlight the passage — even after a full page reload" works
|
||||
(PRD §20 step 4). The acceptable MVP shortcut is `localStorage` (decided
|
||||
explicitly in CE-WP-0002-T08).
|
||||
CE-WP-0002 needs the click-to-reopen flow to survive a page reload (PRD
|
||||
scenario step 4 → "even after a full page reload"). The full persistence
|
||||
design (SQLite local-first vs Postgres server-first) is too large to land
|
||||
inside this slice — `wiki/ArchitectureOverview.md` §10 lays out the bigger
|
||||
picture but the workplan explicitly defers the decision.
|
||||
|
||||
This ADR is the durable home for the real persistence decision: where do
|
||||
documents, annotations, evidence items, links, and sets live in v1.0?
|
||||
The engine already runs `Map`-backed in-memory repositories
|
||||
(`src/engine/repos/in-memory.ts`). To survive reloads we need *some*
|
||||
persistence boundary now, without committing to the long-term store.
|
||||
|
||||
## Options
|
||||
|
||||
- **A. Browser-local only (IndexedDB via `idb` or `dexie`)**
|
||||
- Pros: zero infra; great for a single-user reference workspace.
|
||||
- Cons: no cross-device sync; export/import only via files.
|
||||
|
||||
- **B. Local-first + sync server (e.g. CRDT-backed)**
|
||||
- Pros: matches the long-term vision of a workspace tool; conflict-free
|
||||
multi-device.
|
||||
- Cons: significant infra and CRDT design cost; out of MVP scope.
|
||||
|
||||
- **C. Traditional client/server with a REST or GraphQL API**
|
||||
- Pros: familiar; easy team-sharing story.
|
||||
- Cons: requires hosting; loses the local-first character.
|
||||
- **A. localStorage snapshot (this ADR).** The SPA serializes the entire
|
||||
engine state into a single JSON blob on every mutation and restores it
|
||||
on mount. No new dependencies; no schema migrations; no networking.
|
||||
Per-tab only.
|
||||
- **B. IndexedDB-backed store.** More headroom, more API surface, async
|
||||
reads. Needed eventually for binary blobs (PDF bytes) but overkill for
|
||||
the few hundred annotations the MVP produces.
|
||||
- **C. SQLite via `sql.js` or `wa-sqlite`.** Brings query semantics into
|
||||
the browser. Heavy for the MVP and entangles us with a database we may
|
||||
not keep.
|
||||
- **D. Server-backed persistence from day one.** Requires shipping a
|
||||
backend. Premature.
|
||||
|
||||
## Decision
|
||||
|
||||
(blank — to be answered before the second product slice past MVP.)
|
||||
Adopt **A: localStorage snapshot**, deliberately temporary.
|
||||
|
||||
Implementation lives in `src/engine/persistence.ts`:
|
||||
|
||||
- `captureSnapshot(engine)` returns
|
||||
`{ documents, representations, annotations, evidenceItems }`.
|
||||
- `attachPersister(engine, { key })` subscribes to every mutating engine
|
||||
event and writes a fresh snapshot to `localStorage` after each.
|
||||
- `restoreFromStorage(engine, { key })` reads the snapshot on app mount
|
||||
and hydrates the repos *directly* (bypassing service `create()` calls)
|
||||
so no spurious `*Created` events fire — the persister would otherwise
|
||||
loop on its own writes, and other UI listeners would see "the same
|
||||
annotation was created again" on every reload.
|
||||
- Snapshot is versioned (`SNAPSHOT_VERSION = 1`); a version mismatch
|
||||
throws on restore so a future schema bump is loud.
|
||||
|
||||
`src/work/EngineContext.tsx`'s `EngineProvider` wires this on first mount.
|
||||
A sibling localStorage key holds the last-active `documentId` so reload
|
||||
lands the user back on the same fixture.
|
||||
|
||||
## Why this is acceptable for the MVP
|
||||
|
||||
- The engine never holds PDF bytes — only metadata + selectors + commentary.
|
||||
A typical session is well under 1 MB even with hundreds of annotations,
|
||||
comfortably within the ~5 MB localStorage budget.
|
||||
- The repositories' `create()` signatures already match the shape an
|
||||
eventual durable repo would expose; swapping the implementation is a
|
||||
localised change.
|
||||
- "Survives reload" is the only persistence requirement of CE-WP-0002.
|
||||
Cross-device sync, multi-user access, query-by-tag, history — none are
|
||||
in scope yet.
|
||||
|
||||
## What this defers
|
||||
|
||||
- A real persistence ADR (SQLite local-first vs Postgres server-first vs
|
||||
IndexedDB) for CE-WP-0005+ work.
|
||||
- PDF byte persistence. Today the SPA re-fetches `/fixtures/pdfs/*` on
|
||||
load; bytes do not enter the snapshot.
|
||||
- Multi-tab consistency. Tabs see each other's writes only on reload.
|
||||
- Migrations beyond the version check.
|
||||
|
||||
## Consequences
|
||||
|
||||
(blank)
|
||||
- `src/engine/persistence.ts` is the single point of contact for storage.
|
||||
When the real durable-store ADR lands, that module is what changes.
|
||||
- Tests inject a memory-Storage shim into `attachPersister` /
|
||||
`restoreFromStorage` so they don't depend on a browser environment
|
||||
(see `src/engine/persistence.test.ts`).
|
||||
- Clearing the user's browser storage destroys all annotations — call
|
||||
this out in the README once the MVP ships.
|
||||
|
||||
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+).
|
||||
// Enforces the partition dependency map in wiki/DependencyMap.md §4.
|
||||
//
|
||||
// shared/ and engine/ live in the linked @citation-evidence/engine package;
|
||||
// boundary rules for those partitions are enforced in citation-engine.
|
||||
//
|
||||
// Element types (folders) and allowed importers:
|
||||
// shared : importable by every other element (no internal imports of its own).
|
||||
// engine : imports shared.
|
||||
// shared : importable by every other element (package: citation-engine).
|
||||
// engine : imports shared (package: citation-engine).
|
||||
// anchor : imports shared, engine.
|
||||
// source : imports shared, engine.
|
||||
// binder : imports shared, engine, anchor.
|
||||
// work : imports shared, engine, anchor, source. (NOT binder.)
|
||||
// app : imports anything.
|
||||
//
|
||||
// Path aliases (@shared/*, @engine/*, etc.) come from tsconfig.json paths and
|
||||
// are resolved by eslint-import-resolver-typescript.
|
||||
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import boundaries from "eslint-plugin-boundaries";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import globals from "globals";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const engineSrc = resolve(__dirname, "../citation-engine/src");
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
@@ -41,8 +46,8 @@ export default tseslint.config(
|
||||
typescript: { project: "./tsconfig.json" },
|
||||
},
|
||||
"boundaries/elements": [
|
||||
{ type: "shared", pattern: "src/shared/**" },
|
||||
{ type: "engine", pattern: "src/engine/**" },
|
||||
{ type: "shared", pattern: `${engineSrc}/shared/**` },
|
||||
{ type: "engine", pattern: `${engineSrc}/engine/**` },
|
||||
{ type: "anchor", pattern: "src/anchor/**" },
|
||||
{ type: "source", pattern: "src/source/**" },
|
||||
{ type: "binder", pattern: "src/binder/**" },
|
||||
@@ -68,4 +73,4 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"_schema_version": 1,
|
||||
"_description": "PDF fixture corpus for citation-evidence selector tests. Each entry binds a stable id (used by test code) to a file path, page count, and a verbatim known-good quote with its 1-indexed physical PDF page number. The quote is short, unique within the document, and chosen to round-trip cleanly through the canonical text normalizer.",
|
||||
"_provenance": "Page counts and quotes extracted on 2026-05-24 by reading each PDF directly. The Betriebskosten file is a scanned/handwritten form with noisy OCR text — its quote is taken from the reliably-extracted printed boilerplate, not from the handwritten fields.",
|
||||
"_provenance": "Page counts and quotes extracted on 2026-05-24 by reading each PDF directly, then re-verified on 2026-05-25 against the PDF.js v4 text extractor used by src/source/pdf/extract.ts. The Betriebskosten file is a scanned/handwritten form with noisy OCR text — its known-good quote was updated 2026-05-25 from 'Ich bitte um Überweisung auf das Konto bei' to 'Auf der Rückseite finden Sie Ihre Abrechnung' because PDF.js drops the capital-Ü in the original (the lowercase-ü in 'Rückseite' survives, so the new quote still exercises the umlaut code path).",
|
||||
"fixtures": [
|
||||
{
|
||||
"id": "betriebskosten-2024",
|
||||
"filename": "031-Kemal Güldag Betriebskosten 2024.pdf",
|
||||
"description": "German Betriebskostenabrechnung (utility-cost statement) for a Seeheim apartment — scanned cover letter + filled-in Abrechnung form. OCR-noisy text and handwritten field values. Useful for stress-testing canonical normalization and selector resolution on imperfect extraction.",
|
||||
"page_count": 2,
|
||||
"known_good_quote": "Ich bitte um Überweisung auf das Konto bei",
|
||||
"known_good_quote": "Auf der Rückseite finden Sie Ihre Abrechnung",
|
||||
"known_good_quote_page": 1,
|
||||
"characteristics": ["german", "umlauts", "scanned", "ocr-noisy", "form", "handwritten"]
|
||||
},
|
||||
|
||||
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": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -19,10 +19,17 @@
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@citation-evidence/engine": "link:../citation-engine",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-pdf-highlighter-plus": "^1.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -32,9 +39,11 @@
|
||||
"eslint-plugin-boundaries": "^4.2.2",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"globals": "^15.9.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-static-copy": "^2",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
1813
pnpm-lock.yaml
generated
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