generated from coulomb/repo-seed
Compare commits
35 Commits
779ae0d317
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c292afff6d | |||
| 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 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Kaizen Agents
|
||||||
|
|
||||||
|
Specialized agent personas available on demand via the state-hub MCP.
|
||||||
|
|
||||||
|
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||||
|
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||||
|
|
||||||
|
Common agents:
|
||||||
|
|
||||||
|
| Agent | Category | When to use |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||||
|
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||||
|
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||||
|
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||||
|
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||||
|
| `project-management` | process | Track status, determine next steps |
|
||||||
|
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||||
|
|
||||||
|
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
<!-- TODO: Describe the key design decisions and component structure.
|
||||||
|
Key modules, data flows, external integrations, state machines, etc. -->
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-evidence` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
## First Session Protocol
|
||||||
|
|
||||||
|
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||||
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
|
**Step 1 — Read, don't write**
|
||||||
|
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||||
|
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||||
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
|
**Step 2 — Survey in-progress work**
|
||||||
|
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||||
|
|
||||||
|
**Step 3 — Propose workstreams to Bernd**
|
||||||
|
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||||
|
roadmap phase. **Wait for approval before creating.**
|
||||||
|
|
||||||
|
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||||
|
```
|
||||||
|
workplans/CE-WP-NNNN-<slug>.md ← write this first
|
||||||
|
```
|
||||||
|
Then register in the hub:
|
||||||
|
```
|
||||||
|
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
|
||||||
|
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5 — Record the setup**
|
||||||
|
```
|
||||||
|
add_progress_event(
|
||||||
|
summary="First session: structured infotech into N workstreams, M tasks",
|
||||||
|
event_type="milestone",
|
||||||
|
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||||
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Delete or archive this file once past first session -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Repo boundary
|
||||||
|
|
||||||
|
This repo owns **citation-evidence** only. It does not own:
|
||||||
|
|
||||||
|
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||||
|
- SSH key management → railiance-infra/
|
||||||
|
- State hub code → state-hub/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**Purpose:** Umbrella product: integration shell, reference workspace, shared contracts (wiki/SharedContracts.md, wiki/DependencyMap.md), ADRs, and MVP source code during umbrella-first phase.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** citation-evidence
|
||||||
|
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
|
**Step 1 — Orient**
|
||||||
|
|
||||||
|
Read the offline-safe brief first — it works without a live hub connection:
|
||||||
|
```bash
|
||||||
|
cat .custodian-brief.md
|
||||||
|
```
|
||||||
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
|
```
|
||||||
|
get_domain_summary("infotech")
|
||||||
|
```
|
||||||
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
If the hub is offline: `cd ~/state-hub && make api`
|
||||||
|
|
||||||
|
**Step 2 — Check inbox**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
get_messages(to_agent="citation-evidence", unread_only=True)
|
||||||
|
```
|
||||||
|
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||||
|
requests before proceeding.
|
||||||
|
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-evidence&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Scan workplans**
|
||||||
|
```bash
|
||||||
|
ls workplans/
|
||||||
|
```
|
||||||
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||||
|
2. **Pending tasks** from `workplans/` + any `[repo:citation-evidence]` hub tasks
|
||||||
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
|
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||||
|
4. **Suggested next action** — highest-priority open item
|
||||||
|
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||||
|
|
||||||
|
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
|
|
||||||
|
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||||
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
|
|
||||||
|
**Session close:**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
|
||||||
|
```
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||||
|
```
|
||||||
|
If workplan files were modified, ensure the local copy is up to date first:
|
||||||
|
```bash
|
||||||
|
git -C <repo_path> pull --ff-only
|
||||||
|
cd ~/state-hub && make fix-consistency REPO=citation-evidence
|
||||||
|
```
|
||||||
|
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||||
|
use the combined target which pulls before fixing:
|
||||||
|
```bash
|
||||||
|
cd ~/state-hub && make fix-consistency-remote REPO=citation-evidence
|
||||||
|
```
|
||||||
|
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||||
|
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||||
|
until you pull — intentional to prevent clobbering remote progress.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Stack
|
||||||
|
|
||||||
|
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||||
|
- **Language:**
|
||||||
|
- **Key deps:**
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TODO: Fill in the standard commands for this repo
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
|
||||||
|
# Lint / type check
|
||||||
|
|
||||||
|
# Build / package (if applicable)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
File location: `workplans/CE-WP-NNNN-<slug>.md`
|
||||||
|
ID prefix: `CE-WP-`
|
||||||
|
|
||||||
|
Work items originate as files in this repo **before** being registered in the hub.
|
||||||
|
|
||||||
|
Canonical workplan/workstream frontmatter statuses are:
|
||||||
|
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||||
|
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||||
|
repo state, and `finished` when implementation is complete. `stalled` and
|
||||||
|
`needs_review` are derived health labels, not stored statuses.
|
||||||
|
|
||||||
|
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||||
|
prefix: `YYMMDD-CE-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||||
|
unchanged; the prefix is only for quick visual reference.
|
||||||
|
|
||||||
|
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||||
|
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||||
|
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||||
|
multiple planned phases into a normal workplan.
|
||||||
|
|
||||||
|
Ecosystem todos from other agents arrive as `[repo:citation-evidence]` hub tasks —
|
||||||
|
visible at session start. Pick one up by creating the workplan file, then registering
|
||||||
|
the workstream.
|
||||||
|
|
||||||
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CE-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||||
|
blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||||
18
.custodian-brief.md
Normal file
18
.custodian-brief.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||||
|
# Custodian Brief — citation-evidence
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Last synced:** 2026-06-22 18:27 UTC
|
||||||
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
|
## Active Workstreams
|
||||||
|
|
||||||
|
*(none — repo may need first-session setup)*
|
||||||
|
|
||||||
|
---
|
||||||
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
|
If the state-hub MCP server is reachable, call:
|
||||||
|
`get_domain_summary("infotech")`
|
||||||
|
This provides richer cross-domain context.
|
||||||
|
If the MCP call fails, use this file as your orientation source.
|
||||||
26
.repo-classification.yaml
Normal file
26
.repo-classification.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-22'
|
||||||
|
classified_by: human
|
||||||
|
category: product
|
||||||
|
domain: infotech
|
||||||
|
secondary_domains:
|
||||||
|
- communication
|
||||||
|
- government
|
||||||
|
capability_tags:
|
||||||
|
- citations
|
||||||
|
- evidence
|
||||||
|
- knowledge
|
||||||
|
- traceability
|
||||||
|
- source-management
|
||||||
|
business_stake:
|
||||||
|
- intelligence
|
||||||
|
- legal
|
||||||
|
- product
|
||||||
|
- technology
|
||||||
|
business_mechanics:
|
||||||
|
- control
|
||||||
|
- coordination
|
||||||
|
- adaptation
|
||||||
|
notes: Citation/evidence product; standard §13.5 — human confirmed.
|
||||||
219
AGENTS.md
Normal file
219
AGENTS.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# citation-evidence — Agent Instructions
|
||||||
|
|
||||||
|
## Repo Identity
|
||||||
|
|
||||||
|
**Purpose:** Umbrella product: integration shell, reference workspace, shared contracts (wiki/SharedContracts.md, wiki/DependencyMap.md), ADRs, and MVP source code during umbrella-first phase.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** citation-evidence
|
||||||
|
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||||
|
**Workplan prefix:** `CE-WP-`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Hub Integration
|
||||||
|
|
||||||
|
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||||
|
there is no MCP server for Codex agents.
|
||||||
|
|
||||||
|
| Context | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Local workstation | `http://127.0.0.1:8000` |
|
||||||
|
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||||
|
|
||||||
|
### Orient at session start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Offline brief — works without hub connection
|
||||||
|
cat .custodian-brief.md
|
||||||
|
|
||||||
|
# Active workstreams for this domain
|
||||||
|
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
|
||||||
|
# Check inbox
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-evidence&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Mark a message read:
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log progress (required at session close)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"summary": "what was done",
|
||||||
|
"event_type": "note",
|
||||||
|
"author": "codex",
|
||||||
|
"workstream_id": "<uuid>",
|
||||||
|
"task_id": "<uuid>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `workstream_id` / `task_id` when not applicable.
|
||||||
|
|
||||||
|
### Update task status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status": "progress"}'
|
||||||
|
# values: wait | todo | progress | done | cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flag a task for human review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
**Start:**
|
||||||
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
|
2. Check inbox: `GET /messages/?to_agent=citation-evidence&unread_only=true`; mark read
|
||||||
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
|
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
|
**During work:**
|
||||||
|
- Update task statuses in workplan files as tasks progress
|
||||||
|
- Record significant decisions via `POST /decisions/`
|
||||||
|
|
||||||
|
**Close:**
|
||||||
|
1. Update workplan file task statuses to reflect progress
|
||||||
|
2. Log: `POST /progress/` with a summary of what changed
|
||||||
|
3. Note for the custodian operator: after workplan file changes, run from
|
||||||
|
`~/state-hub`:
|
||||||
|
```bash
|
||||||
|
make fix-consistency REPO=citation-evidence
|
||||||
|
```
|
||||||
|
This syncs task status from files into the hub DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-evidence` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
Work items originate as files in this repo — not in the hub. The hub is a
|
||||||
|
read/cache/index layer that rebuilds from files.
|
||||||
|
|
||||||
|
**File location:** `workplans/CITATION-WP-NNNN-<slug>.md`
|
||||||
|
|
||||||
|
**Archived location:** finished workplans may move to
|
||||||
|
`workplans/archived/YYMMDD-CITATION-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
|
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||||
|
this only for low-risk work completed directly; create a normal workplan for
|
||||||
|
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||||
|
|
||||||
|
**Frontmatter:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: CITATION-WP-NNNN
|
||||||
|
type: workplan
|
||||||
|
title: "..."
|
||||||
|
domain: infotech
|
||||||
|
repo: citation-evidence
|
||||||
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
|
owner: codex
|
||||||
|
topic_slug: ...
|
||||||
|
created: "YYYY-MM-DD"
|
||||||
|
updated: "YYYY-MM-DD"
|
||||||
|
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
|
```
|
||||||
|
## Task Title
|
||||||
|
|
||||||
|
` ` `task
|
||||||
|
id: CITATION-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
` ` `
|
||||||
|
|
||||||
|
Task description text.
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
To create a new workplan:
|
||||||
|
1. Write the file following the format above
|
||||||
|
2. Notify the custodian operator to run `make fix-consistency REPO=citation-evidence`
|
||||||
|
(or send a message to the hub agent via `POST /messages/`)
|
||||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# citation-evidence — Claude Code Instructions
|
||||||
|
|
||||||
|
@SCOPE.md
|
||||||
|
@.claude/rules/repo-identity.md
|
||||||
|
@.claude/rules/session-protocol.md
|
||||||
|
@.claude/rules/first-session.md
|
||||||
|
@.claude/rules/workplan-convention.md
|
||||||
|
@.claude/rules/stack-and-commands.md
|
||||||
|
@.claude/rules/architecture.md
|
||||||
|
@.claude/rules/repo-boundary.md
|
||||||
|
@.claude/rules/credential-routing.md
|
||||||
|
@.claude/rules/agents.md
|
||||||
27
Makefile
Normal file
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.PHONY: preview dev build test typecheck lint check-install
|
||||||
|
|
||||||
|
# Build the production bundle then serve it locally with vite preview.
|
||||||
|
# Prints the URL (default http://localhost:4173/) once the server is ready.
|
||||||
|
preview:
|
||||||
|
pnpm build
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# Dev server with HMR (http://localhost:5173/).
|
||||||
|
dev:
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
build:
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
test:
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
pnpm typecheck
|
||||||
|
|
||||||
|
lint:
|
||||||
|
pnpm lint
|
||||||
|
|
||||||
|
# Verify sibling citation-engine checkout, link dependency, and toolchain.
|
||||||
|
check-install:
|
||||||
|
@bash scripts/check-install.sh
|
||||||
18
README.md
18
README.md
@@ -4,9 +4,9 @@ A document-centered evidence workspace for capturing, managing, presenting,
|
|||||||
and re-opening citations. The umbrella over the six-package design described
|
and re-opening citations. The umbrella over the six-package design described
|
||||||
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
|
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
|
||||||
|
|
||||||
During the MVP all code lives here under `src/` (see "Repository layout"
|
Shared types and engine services live in the extracted
|
||||||
below). Sister repos hold INTENT only — code migrates outward when each
|
[`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`).
|
||||||
subsystem stabilises.
|
Remaining partitions stay under `src/` until each subsystem extracts.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md.
|
|||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
|
Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep).
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
shared/ # vocabulary, types, pure helpers → becomes part of citation-engine
|
|
||||||
engine/ # services, repositories, event bus → becomes part of citation-engine
|
|
||||||
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
|
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
|
||||||
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
|
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
|
||||||
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
|
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
|
||||||
@@ -41,9 +41,9 @@ repo is intended to be a `git mv` plus a `package.json` cut — nothing more.
|
|||||||
|
|
||||||
## Sister repos
|
## Sister repos
|
||||||
|
|
||||||
Peers under `~/`; each holds INTENT.md only during MVP:
|
Peers under `~/`:
|
||||||
|
|
||||||
- [`~/citation-engine`](../citation-engine/) — shared model + engine services
|
- [`~/citation-engine`](../citation-engine/) — **extracted** shared model + engine (`@citation-evidence/engine`)
|
||||||
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
|
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
|
||||||
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
|
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
|
||||||
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
|
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
|
||||||
@@ -54,6 +54,8 @@ Peers under `~/`; each holds INTENT.md only during MVP:
|
|||||||
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
|
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# citation-engine must be checked out next to this repo (../citation-engine)
|
||||||
|
make check-install # diagnose layout problems before install
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev # vite dev server (once src/app/ has a real entry)
|
pnpm dev # vite dev server (once src/app/ has a real entry)
|
||||||
pnpm test # vitest one-shot
|
pnpm test # vitest one-shot
|
||||||
@@ -66,7 +68,7 @@ pnpm build # production bundle
|
|||||||
## Workplans (Ralph)
|
## Workplans (Ralph)
|
||||||
|
|
||||||
Workplans drive incremental implementation through the ralph loop. The harness
|
Workplans drive incremental implementation through the ralph loop. The harness
|
||||||
lives in `~/ralph-workplan/`; see `workplans/README.md` for the active list
|
lives in `~/ralph-workplan/`; see `docs/mvp-workplans-index.md` for the active list
|
||||||
and ordering.
|
and ordering.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
137
SCOPE.md
Normal file
137
SCOPE.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# SCOPE
|
||||||
|
|
||||||
|
> This file helps you quickly understand what this repository is about,
|
||||||
|
> when it is relevant, and when it is not.
|
||||||
|
> It is intentionally lightweight and may be incomplete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-liner
|
||||||
|
|
||||||
|
<!-- Describe the purpose of this repository in one precise sentence. -->
|
||||||
|
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Idea
|
||||||
|
|
||||||
|
<!-- What is the main capability or idea behind this repository? -->
|
||||||
|
<!-- What problem does it try to solve? -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In Scope
|
||||||
|
|
||||||
|
<!-- What this repository is responsible for. -->
|
||||||
|
<!-- Be explicit and concrete. -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
<!-- What this repository deliberately does NOT do. -->
|
||||||
|
<!-- This is often more important than "In Scope". -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevant When
|
||||||
|
|
||||||
|
<!-- When should someone consider using or exploring this repository? -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not Relevant When
|
||||||
|
|
||||||
|
<!-- When should someone ignore this repository? -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
<!-- Rough indication of maturity. No strict format required. -->
|
||||||
|
|
||||||
|
- Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
|
||||||
|
- Implementation: <!-- e.g. idea / partial / substantial / complete -->
|
||||||
|
- Stability: <!-- e.g. unstable / evolving / stable -->
|
||||||
|
- Usage: <!-- e.g. none / personal / internal / production -->
|
||||||
|
|
||||||
|
<!-- Add any notes that help set expectations. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Fits
|
||||||
|
|
||||||
|
<!-- Where does this repository sit in the bigger picture? -->
|
||||||
|
|
||||||
|
- Upstream dependencies:
|
||||||
|
- Downstream consumers:
|
||||||
|
- Often used with:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
<!-- Terms that are important to understand this repo. -->
|
||||||
|
<!-- Especially useful if naming differs from other repos. -->
|
||||||
|
|
||||||
|
- Preferred terms:
|
||||||
|
- Also known as:
|
||||||
|
- Potentially confusing terms:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related / Overlapping Repositories
|
||||||
|
|
||||||
|
<!-- List repositories that have similar or adjacent responsibilities. -->
|
||||||
|
<!-- Helps detect duplication and navigate the ecosystem. -->
|
||||||
|
|
||||||
|
- <repo-name> — <!-- how it relates -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Oriented
|
||||||
|
|
||||||
|
<!-- If someone decides to look deeper, where should they start? -->
|
||||||
|
|
||||||
|
- Start with:
|
||||||
|
- Key files / directories:
|
||||||
|
- Entry points:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provided Capabilities
|
||||||
|
|
||||||
|
<!-- What can this repo's domain provide to other domains on request? -->
|
||||||
|
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
|
||||||
|
<!-- Remove the examples and add your own, or leave empty if none. -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
```capability
|
||||||
|
type: infrastructure
|
||||||
|
title: Example capability title
|
||||||
|
description: What this capability provides, in one or two sentences.
|
||||||
|
keywords: [keyword1, keyword2, keyword3]
|
||||||
|
```
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<!-- Anything else worth knowing. Keep it short. -->
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
# ADR-0002 — Monorepo vs polyrepo for the six subsystems
|
# ADR-0002 — Monorepo vs polyrepo for the six subsystems
|
||||||
|
|
||||||
- Status: proposed
|
- Status: accepted
|
||||||
- Date: 2026-05-24
|
- Date: 2026-05-24
|
||||||
- Workplan: CE-WP-0001-T07 (stub)
|
- Decided: 2026-06-22
|
||||||
|
- Workplan: CENG-WP-0002-T01
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The umbrella-first MVP lives entirely in `citation-evidence/` under
|
The umbrella-first MVP lives entirely in `citation-evidence/` under
|
||||||
`src/{shared,engine,anchor,source,binder,work,app}/`. Each folder is named
|
`src/{anchor,source,binder,work,app}/` with shared types and engine services
|
||||||
after its eventual extracted package. At some point — driven by an external
|
in the extracted `@citation-evidence/engine` package (`citation-engine` repo).
|
||||||
consumer needing one subsystem, or by independent release cadence — code
|
|
||||||
will move out into its sister repo.
|
Each remaining folder is named after its eventual extracted package. At some
|
||||||
|
point — driven by an external consumer needing one subsystem, or by independent
|
||||||
|
release cadence — code will move out into its sister repo.
|
||||||
|
|
||||||
We need a written answer to: when that moment comes, do we (a) keep one
|
We need a written answer to: when that moment comes, do we (a) keep one
|
||||||
repository with pnpm workspaces, (b) split into six independent repos with
|
repository with pnpm workspaces, (b) split into six independent repos with
|
||||||
@@ -43,8 +46,29 @@ across the boundary.
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
(blank — to be answered before the first subsystem extraction lands.)
|
**B — six independent repos with published packages**, using **`link:` sibling
|
||||||
|
dependencies during local development** until a registry is configured.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
1. The ecosystem is already organized as six sister repos plus the umbrella;
|
||||||
|
independent repos match the documented architecture.
|
||||||
|
2. `citation-engine` extraction (`CENG-WP-0001`) and umbrella wireup
|
||||||
|
(`CE-WP-0009`) prove the `link:../citation-engine` dev workflow.
|
||||||
|
3. Publishing can be deferred — no registry is configured yet — without
|
||||||
|
blocking extraction of the remaining subsystems.
|
||||||
|
4. Option C adds tooling overhead before any external consumer exists.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
(blank)
|
- **Local dev:** sister repos sit as siblings under `~/` (or equivalent).
|
||||||
|
Consumers declare `"@citation-evidence/engine": "link:../citation-engine"`.
|
||||||
|
- **Publishing:** when a registry is chosen, bump `@citation-evidence/engine`
|
||||||
|
semver and replace `link:` with the registry version in consumer repos.
|
||||||
|
- **Contracts:** `citation-evidence/wiki/SharedContracts.md` stays authoritative;
|
||||||
|
`citation-engine/wiki/SharedContracts.md` is a conformance copy (see
|
||||||
|
`citation-engine/wiki/README.md`).
|
||||||
|
- **Versioning:** engine package semver tracks API/contract changes; umbrella
|
||||||
|
and sister repos pin or range-pin on publish.
|
||||||
|
- **CI:** each repo runs its own test/lint pipeline; cross-repo integration
|
||||||
|
tests remain in `citation-evidence` until subsystems extract fully.
|
||||||
59
docs/mvp-workplans-index.md
Normal file
59
docs/mvp-workplans-index.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# MVP Workplans
|
||||||
|
|
||||||
|
MVP workplans for the citation-evidence umbrella repo. CE-WP-0001..0006
|
||||||
|
delivered the PRD §20 reference scenario and Forms/Review UX polish.
|
||||||
|
CE-WP-0007 delivered Capture-view polish including field add/edit UX.
|
||||||
|
CE-WP-0008 fixes capture field value persistence and viewport scroll reliability.
|
||||||
|
|
||||||
|
| Workplan | Title | Status |
|
||||||
|
|----------|----------------------------------------|--------|
|
||||||
|
| `CE-WP-0001` | Foundations — scaffold, folders, lint rules, normalize, fixtures | done |
|
||||||
|
| `CE-WP-0002` | PDF review slice — engine types, anchor, source, viewer, sidebar | done |
|
||||||
|
| `CE-WP-0003` | Form binding + visual guide — EvidenceLink, rect registry, overlay | done |
|
||||||
|
| `CE-WP-0004` | Citation card export — Markdown + HTML renderers, sidebar export | done |
|
||||||
|
| `CE-WP-0005` | Demo sessions — uploads, named sessions, ZIP export/import | done |
|
||||||
|
| `CE-WP-0006` | Forms & review UX refinements — blob fix, scroll centre, linking | done |
|
||||||
|
| `CE-WP-0007` | Capture view polish — scroll, linking, layout, rename, field UX | done |
|
||||||
|
| `CE-WP-0008` | Capture content editing & viewport scroll reliability | done |
|
||||||
|
|
||||||
|
## Post-MVP — extraction and distribution
|
||||||
|
|
||||||
|
| Workplan | Repo | Title | Status |
|
||||||
|
|----------|------|-------|--------|
|
||||||
|
| `CENG-WP-0001` | citation-engine | Extract engine from umbrella | done |
|
||||||
|
| `CE-WP-0009` | citation-evidence | Wire umbrella to `@citation-evidence/engine` | done |
|
||||||
|
| `CENG-WP-0002` | citation-engine | Package distribution (ADR-0002, publish prep) | done |
|
||||||
|
|
||||||
|
`CE-WP-0009` depends on `CENG-WP-0001`. `CENG-WP-0002` can run in parallel;
|
||||||
|
publish tasks wait on ADR-0002 resolution.
|
||||||
|
|
||||||
|
## Order
|
||||||
|
|
||||||
|
CE-WP-0001..0004 are strictly sequential. CE-WP-0005 depends on 0004.
|
||||||
|
CE-WP-0006 depends on 0005. CE-WP-0007 depends on 0006. CE-WP-0008 depends on 0007:
|
||||||
|
|
||||||
|
```
|
||||||
|
/ralph-workplan workplans/CE-WP-0008-capture-content-editing.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to run a workplan
|
||||||
|
|
||||||
|
```
|
||||||
|
/ralph-workplan workplans/CE-WP-0001-foundations.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Ralph drives the loop and retires automatically when all tasks in the
|
||||||
|
workplan are marked `done`. See `~/.claude/plugins/ralph-workplan/ralph-workplan.md`.
|
||||||
|
|
||||||
|
## Acceptance for MVP
|
||||||
|
|
||||||
|
The first reference scenario from PRD §20 runs end-to-end:
|
||||||
|
|
||||||
|
1. Create a collection
|
||||||
|
2. Upload a PDF
|
||||||
|
3. Select a passage, add commentary, create an evidence item
|
||||||
|
4. Open a side-by-side form
|
||||||
|
5. Link the evidence item to a form field
|
||||||
|
6. Focus the field → field, evidence card, and PDF passage all highlighted
|
||||||
|
7. SVG guide visible between field → card → highlight
|
||||||
|
8. Export evidence as a Markdown citation card
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
// ESLint flat config (ESLint 9+).
|
// ESLint flat config (ESLint 9+).
|
||||||
// Enforces the partition dependency map in wiki/DependencyMap.md §4.
|
// Enforces the partition dependency map in wiki/DependencyMap.md §4.
|
||||||
//
|
//
|
||||||
|
// shared/ and engine/ live in the linked @citation-evidence/engine package;
|
||||||
|
// boundary rules for those partitions are enforced in citation-engine.
|
||||||
|
//
|
||||||
// Element types (folders) and allowed importers:
|
// Element types (folders) and allowed importers:
|
||||||
// shared : importable by every other element (no internal imports of its own).
|
// shared : importable by every other element (package: citation-engine).
|
||||||
// engine : imports shared.
|
// engine : imports shared (package: citation-engine).
|
||||||
// anchor : imports shared, engine.
|
// anchor : imports shared, engine.
|
||||||
// source : imports shared, engine.
|
// source : imports shared, engine.
|
||||||
// binder : imports shared, engine, anchor.
|
// binder : imports shared, engine, anchor.
|
||||||
// work : imports shared, engine, anchor, source. (NOT binder.)
|
// work : imports shared, engine, anchor, source. (NOT binder.)
|
||||||
// app : imports anything.
|
// app : imports anything.
|
||||||
//
|
|
||||||
// Path aliases (@shared/*, @engine/*, etc.) come from tsconfig.json paths and
|
|
||||||
// are resolved by eslint-import-resolver-typescript.
|
|
||||||
|
|
||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import boundaries from "eslint-plugin-boundaries";
|
import boundaries from "eslint-plugin-boundaries";
|
||||||
import importPlugin from "eslint-plugin-import";
|
import importPlugin from "eslint-plugin-import";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const engineSrc = resolve(__dirname, "../citation-engine/src");
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
@@ -41,8 +46,8 @@ export default tseslint.config(
|
|||||||
typescript: { project: "./tsconfig.json" },
|
typescript: { project: "./tsconfig.json" },
|
||||||
},
|
},
|
||||||
"boundaries/elements": [
|
"boundaries/elements": [
|
||||||
{ type: "shared", pattern: "src/shared/**" },
|
{ type: "shared", pattern: `${engineSrc}/shared/**` },
|
||||||
{ type: "engine", pattern: "src/engine/**" },
|
{ type: "engine", pattern: `${engineSrc}/engine/**` },
|
||||||
{ type: "anchor", pattern: "src/anchor/**" },
|
{ type: "anchor", pattern: "src/anchor/**" },
|
||||||
{ type: "source", pattern: "src/source/**" },
|
{ type: "source", pattern: "src/source/**" },
|
||||||
{ type: "binder", pattern: "src/binder/**" },
|
{ type: "binder", pattern: "src/binder/**" },
|
||||||
|
|||||||
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.
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"typecheck": "tsc -b --noEmit"
|
"typecheck": "tsc -b --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@citation-evidence/engine": "link:../citation-engine",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"pdfjs-dist": "^4.4.168",
|
"pdfjs-dist": "^4.4.168",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
|
"vite-plugin-static-copy": "^2",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
194
pnpm-lock.yaml
generated
194
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@citation-evidence/engine':
|
||||||
|
specifier: link:../citation-engine
|
||||||
|
version: link:../citation-engine
|
||||||
jszip:
|
jszip:
|
||||||
specifier: ^3.10.1
|
specifier: ^3.10.1
|
||||||
version: 3.10.1
|
version: 3.10.1
|
||||||
@@ -72,6 +75,9 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.21(@types/node@20.19.41)
|
version: 5.4.21(@types/node@20.19.41)
|
||||||
|
vite-plugin-static-copy:
|
||||||
|
specifier: ^2
|
||||||
|
version: 2.3.2(vite@5.4.21(@types/node@20.19.41))
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.5
|
specifier: ^2.0.5
|
||||||
version: 2.1.9(@types/node@20.19.41)(happy-dom@20.9.0)
|
version: 2.1.9(@types/node@20.19.41)(happy-dom@20.9.0)
|
||||||
@@ -477,6 +483,18 @@ packages:
|
|||||||
'@emnapi/core': ^1.7.1
|
'@emnapi/core': ^1.7.1
|
||||||
'@emnapi/runtime': ^1.7.1
|
'@emnapi/runtime': ^1.7.1
|
||||||
|
|
||||||
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@nodelib/fs.stat@2.0.5':
|
||||||
|
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@nodelib/fs.walk@1.2.8':
|
||||||
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39':
|
'@nolyfill/is-core-module@1.0.39':
|
||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
@@ -1374,6 +1392,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
anymatch@3.1.3:
|
||||||
|
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
@@ -1432,6 +1454,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
binary-extensions@2.3.0:
|
||||||
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
brace-expansion@1.1.14:
|
brace-expansion@1.1.14:
|
||||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||||
|
|
||||||
@@ -1483,6 +1509,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
|
chokidar@3.6.0:
|
||||||
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
|
engines: {node: '>= 8.10.0'}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
@@ -1759,12 +1789,19 @@ packages:
|
|||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
fast-glob@3.3.3:
|
||||||
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
|
engines: {node: '>=8.6.0'}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0:
|
fast-json-stable-stringify@2.1.0:
|
||||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||||
|
|
||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fastq@1.20.1:
|
||||||
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1797,6 +1834,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
fs-extra@11.3.5:
|
||||||
|
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1839,6 +1880,10 @@ packages:
|
|||||||
get-tsconfig@4.14.0:
|
get-tsconfig@4.14.0:
|
||||||
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
||||||
|
|
||||||
|
glob-parent@5.1.2:
|
||||||
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -1859,6 +1904,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11:
|
||||||
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
happy-dom@20.9.0:
|
happy-dom@20.9.0:
|
||||||
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
|
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -1928,6 +1976,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
|
resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-binary-path@2.1.0:
|
||||||
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
is-boolean-object@1.2.2:
|
is-boolean-object@1.2.2:
|
||||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2058,6 +2110,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonfile@6.2.1:
|
||||||
|
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
@@ -2107,10 +2162,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
merge2@1.4.1:
|
||||||
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
micromatch@4.0.7:
|
micromatch@4.0.7:
|
||||||
resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
|
resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
micromatch@4.0.8:
|
||||||
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -2145,6 +2208,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
normalize-path@3.0.0:
|
||||||
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2193,6 +2260,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-map@7.0.4:
|
||||||
|
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
pako@1.0.11:
|
pako@1.0.11:
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
@@ -2262,6 +2333,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
queue-microtask@1.2.3:
|
||||||
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
re-resizable@6.11.2:
|
re-resizable@6.11.2:
|
||||||
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
|
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2339,6 +2413,10 @@ packages:
|
|||||||
readable-stream@2.3.8:
|
readable-stream@2.3.8:
|
||||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
|
readdirp@3.6.0:
|
||||||
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2364,11 +2442,18 @@ packages:
|
|||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
reusify@1.1.0:
|
||||||
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
rollup@4.60.4:
|
rollup@4.60.4:
|
||||||
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
|
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
run-parallel@1.2.0:
|
||||||
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
safe-array-concat@1.1.4:
|
safe-array-concat@1.1.4:
|
||||||
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
|
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -2572,6 +2657,10 @@ packages:
|
|||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
universalify@2.0.1:
|
||||||
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
unrs-resolver@1.12.2:
|
unrs-resolver@1.12.2:
|
||||||
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
|
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
|
||||||
|
|
||||||
@@ -2612,6 +2701,12 @@ packages:
|
|||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
vite-plugin-static-copy@2.3.2:
|
||||||
|
resolution: {integrity: sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^5.0.0 || ^6.0.0
|
||||||
|
|
||||||
vite@5.4.21:
|
vite@5.4.21:
|
||||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -3075,6 +3170,18 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.2
|
'@tybys/wasm-util': 0.10.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
run-parallel: 1.2.0
|
||||||
|
|
||||||
|
'@nodelib/fs.stat@2.0.5': {}
|
||||||
|
|
||||||
|
'@nodelib/fs.walk@1.2.8':
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
|
fastq: 1.20.1
|
||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
'@pdf-lib/standard-fonts@1.0.0':
|
'@pdf-lib/standard-fonts@1.0.0':
|
||||||
@@ -3942,6 +4049,11 @@ snapshots:
|
|||||||
|
|
||||||
ansi-styles@5.2.0: {}
|
ansi-styles@5.2.0: {}
|
||||||
|
|
||||||
|
anymatch@3.1.3:
|
||||||
|
dependencies:
|
||||||
|
normalize-path: 3.0.0
|
||||||
|
picomatch: 2.3.2
|
||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
aria-hidden@1.2.6:
|
||||||
@@ -4016,6 +4128,8 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.32: {}
|
baseline-browser-mapping@2.10.32: {}
|
||||||
|
|
||||||
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.14:
|
brace-expansion@1.1.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
@@ -4075,6 +4189,18 @@ snapshots:
|
|||||||
|
|
||||||
check-error@2.1.3: {}
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
|
chokidar@3.6.0:
|
||||||
|
dependencies:
|
||||||
|
anymatch: 3.1.3
|
||||||
|
braces: 3.0.3
|
||||||
|
glob-parent: 5.1.2
|
||||||
|
is-binary-path: 2.1.0
|
||||||
|
is-glob: 4.0.3
|
||||||
|
normalize-path: 3.0.0
|
||||||
|
readdirp: 3.6.0
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -4448,10 +4574,22 @@ snapshots:
|
|||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
|
fast-glob@3.3.3:
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
'@nodelib/fs.walk': 1.2.8
|
||||||
|
glob-parent: 5.1.2
|
||||||
|
merge2: 1.4.1
|
||||||
|
micromatch: 4.0.8
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fastq@1.20.1:
|
||||||
|
dependencies:
|
||||||
|
reusify: 1.1.0
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -4480,6 +4618,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|
||||||
|
fs-extra@11.3.5:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
jsonfile: 6.2.1
|
||||||
|
universalify: 2.0.1
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4530,6 +4674,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
glob-parent@5.1.2:
|
||||||
|
dependencies:
|
||||||
|
is-glob: 4.0.3
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -4545,6 +4693,8 @@ snapshots:
|
|||||||
|
|
||||||
gopd@1.2.0: {}
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
happy-dom@20.9.0:
|
happy-dom@20.9.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 20.19.41
|
||||||
@@ -4618,6 +4768,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-bigints: 1.1.0
|
has-bigints: 1.1.0
|
||||||
|
|
||||||
|
is-binary-path@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
binary-extensions: 2.3.0
|
||||||
|
|
||||||
is-boolean-object@1.2.2:
|
is-boolean-object@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -4738,6 +4892,12 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonfile@6.2.1:
|
||||||
|
dependencies:
|
||||||
|
universalify: 2.0.1
|
||||||
|
optionalDependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
lie: 3.3.0
|
lie: 3.3.0
|
||||||
@@ -4788,11 +4948,18 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
micromatch@4.0.7:
|
micromatch@4.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.2
|
picomatch: 2.3.2
|
||||||
|
|
||||||
|
micromatch@4.0.8:
|
||||||
|
dependencies:
|
||||||
|
braces: 3.0.3
|
||||||
|
picomatch: 2.3.2
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.6
|
brace-expansion: 5.0.6
|
||||||
@@ -4820,6 +4987,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.46: {}
|
node-releases@2.0.46: {}
|
||||||
|
|
||||||
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -4885,6 +5054,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
p-map@7.0.4: {}
|
||||||
|
|
||||||
pako@1.0.11: {}
|
pako@1.0.11: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
@@ -4944,6 +5115,8 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
re-resizable@6.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
re-resizable@6.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -5045,6 +5218,10 @@ snapshots:
|
|||||||
string_decoder: 1.1.1
|
string_decoder: 1.1.1
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
readdirp@3.6.0:
|
||||||
|
dependencies:
|
||||||
|
picomatch: 2.3.2
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -5085,6 +5262,8 @@ snapshots:
|
|||||||
path-parse: 1.0.7
|
path-parse: 1.0.7
|
||||||
supports-preserve-symlinks-flag: 1.0.0
|
supports-preserve-symlinks-flag: 1.0.0
|
||||||
|
|
||||||
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rollup@4.60.4:
|
rollup@4.60.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -5116,6 +5295,10 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.60.4
|
'@rollup/rollup-win32-x64-msvc': 4.60.4
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
run-parallel@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
safe-array-concat@1.1.4:
|
safe-array-concat@1.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -5352,6 +5535,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
unrs-resolver@1.12.2:
|
unrs-resolver@1.12.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
napi-postinstall: 0.3.4
|
napi-postinstall: 0.3.4
|
||||||
@@ -5424,6 +5609,15 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
vite-plugin-static-copy@2.3.2(vite@5.4.21(@types/node@20.19.41)):
|
||||||
|
dependencies:
|
||||||
|
chokidar: 3.6.0
|
||||||
|
fast-glob: 3.3.3
|
||||||
|
fs-extra: 11.3.5
|
||||||
|
p-map: 7.0.4
|
||||||
|
picocolors: 1.1.1
|
||||||
|
vite: 5.4.21(@types/node@20.19.41)
|
||||||
|
|
||||||
vite@5.4.21(@types/node@20.19.41):
|
vite@5.4.21(@types/node@20.19.41):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
|
|||||||
12
registry/README.md
Normal file
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
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
id: capability.infotech.citation-evidence-workspace
|
||||||
|
name: Citation Evidence Workspace
|
||||||
|
summary: Document-centered evidence workspace for capturing, managing, presenting, and re-opening citations
|
||||||
|
— the umbrella application over the citation-evidence six-package design.
|
||||||
|
owner: citation-evidence
|
||||||
|
status: draft
|
||||||
|
domain: infotech
|
||||||
|
tags:
|
||||||
|
- citation
|
||||||
|
- evidence
|
||||||
|
- workspace
|
||||||
|
maturity:
|
||||||
|
discovery:
|
||||||
|
current: D4
|
||||||
|
target: D5
|
||||||
|
confidence: medium
|
||||||
|
rationale: INTENT.md, wiki/ArchitectureOverview.md, wiki/SharedContracts.md, wiki/DependencyMap.md,
|
||||||
|
and ADRs under docs/decisions/ describe the umbrella-first six-package design and partition boundaries
|
||||||
|
in detail.
|
||||||
|
availability:
|
||||||
|
current: A1
|
||||||
|
target: A3
|
||||||
|
confidence: medium
|
||||||
|
rationale: Vite/TS app runnable locally (`npm run dev`/`build`); depends on citation-engine via a
|
||||||
|
local workspace link; no hosted deployment yet.
|
||||||
|
external_evidence:
|
||||||
|
completeness:
|
||||||
|
level: C1
|
||||||
|
confidence: low
|
||||||
|
basis: scope_vs_intent_and_consumer_expectations
|
||||||
|
satisfied_expectations:
|
||||||
|
- umbrella app wiring citation-engine into a usable workspace
|
||||||
|
- documented partition boundaries for future package extraction
|
||||||
|
broken_expectations: []
|
||||||
|
out_of_scope_expectations: []
|
||||||
|
reliability:
|
||||||
|
level: R0
|
||||||
|
confidence: low
|
||||||
|
basis: consumer_quality_signals
|
||||||
|
known_reliability_risks:
|
||||||
|
- most subsystem partitions still live under src/ pending extraction; only citation-engine has been
|
||||||
|
extracted so far
|
||||||
|
discovery:
|
||||||
|
intent: Give users a working evidence workspace today while the six-package design is incrementally
|
||||||
|
extracted, keeping citation-engine as the stable shared core.
|
||||||
|
includes:
|
||||||
|
- document-centered evidence capture, management, and presentation UI
|
||||||
|
- wiring to citation-engine
|
||||||
|
excludes:
|
||||||
|
- subsystems not yet extracted into their own packages (see wiki/DependencyMap.md)
|
||||||
|
assumptions: []
|
||||||
|
use_cases: []
|
||||||
|
research_memos: []
|
||||||
|
availability:
|
||||||
|
current_level: A1
|
||||||
|
target_level: A3
|
||||||
|
current_artifacts:
|
||||||
|
- Vite/TS application, runnable via `npm run dev`
|
||||||
|
target_artifacts: []
|
||||||
|
consumption_modes:
|
||||||
|
- application (local dev server)
|
||||||
|
relations:
|
||||||
|
depends_on: []
|
||||||
|
supports: []
|
||||||
|
related_to: []
|
||||||
|
evidence:
|
||||||
|
documentation:
|
||||||
|
- INTENT.md
|
||||||
|
- wiki/ArchitectureOverview.md
|
||||||
|
- wiki/SharedContracts.md
|
||||||
|
tests:
|
||||||
|
- tests/
|
||||||
|
- vitest.config.ts
|
||||||
|
consumer_feedback: []
|
||||||
|
bug_reports: []
|
||||||
|
incidents: []
|
||||||
|
consumer_guidance:
|
||||||
|
recommended_for:
|
||||||
|
- evaluating or prototyping the citation-evidence workspace UI
|
||||||
|
not_recommended_for:
|
||||||
|
- production deployment (no hosted build/deployment artifact yet)
|
||||||
|
known_limitations:
|
||||||
|
- umbrella-first MVP; five of six planned packages remain unextracted
|
||||||
|
promotion_history: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Citation Evidence Workspace
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`citation-evidence` is the umbrella workspace application over a planned six-package design for document-centered citation evidence. `citation-engine` has already been extracted as the shared domain core; remaining partitions stay under `src/` pending extraction.
|
||||||
|
|
||||||
|
## Assessment notes
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
INTENT.md, wiki/ArchitectureOverview.md, wiki/SharedContracts.md, wiki/DependencyMap.md, and ADRs under docs/decisions/ describe the umbrella-first six-package design and partition boundaries in detail.
|
||||||
|
|
||||||
|
### Availability
|
||||||
|
|
||||||
|
Vite/TS app runnable locally (`npm run dev`/`build`); depends on citation-engine via a local workspace link; no hosted deployment yet.
|
||||||
|
|
||||||
|
### Completeness
|
||||||
|
|
||||||
|
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
|
||||||
|
(reuse-surface). No external consumer feedback exists yet; levels reflect
|
||||||
|
scope-vs-intent documentation quality, not internal code quality.
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
No production consumer telemetry exists yet; reliability level is
|
||||||
|
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
|
||||||
|
|
||||||
|
## Promotion checklist
|
||||||
|
|
||||||
|
- [x] ID follows `capability.<domain>.<name>` pattern
|
||||||
|
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
|
||||||
|
- [x] `external_evidence` is populated separately from `maturity`
|
||||||
|
- [ ] Relations reference valid capability IDs (none yet)
|
||||||
|
- [x] Index entry added in `registry/indexes/capabilities.yaml`
|
||||||
19
registry/indexes/capabilities.yaml
Normal file
19
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: 1
|
||||||
|
updated: '2026-07-06'
|
||||||
|
domain: helix_forge
|
||||||
|
capabilities:
|
||||||
|
- id: capability.infotech.citation-evidence-workspace
|
||||||
|
name: Citation Evidence Workspace
|
||||||
|
summary: Document-centered evidence workspace for capturing, managing, presenting, and re-opening citations
|
||||||
|
— the umbrella application over the citation-evidence six-package design.
|
||||||
|
vector: D4 / A1 / C1 / R0
|
||||||
|
domain: infotech
|
||||||
|
status: draft
|
||||||
|
owner: citation-evidence
|
||||||
|
path: registry/capabilities/capability.infotech.citation-evidence-workspace.md
|
||||||
|
tags:
|
||||||
|
- citation
|
||||||
|
- evidence
|
||||||
|
- workspace
|
||||||
|
consumption_modes:
|
||||||
|
- application (local dev server)
|
||||||
162
scripts/check-install.sh
Executable file
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;
|
||||||
|
}
|
||||||
@@ -14,7 +14,15 @@
|
|||||||
* will build the real PDFViewerAdapter on top of this lessons-learned.
|
* will build the real PDFViewerAdapter on top of this lessons-learned.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
PdfHighlighter,
|
PdfHighlighter,
|
||||||
PdfLoader,
|
PdfLoader,
|
||||||
@@ -26,12 +34,22 @@ import {
|
|||||||
type PdfSelection,
|
type PdfSelection,
|
||||||
type ScaledPosition,
|
type ScaledPosition,
|
||||||
} from "react-pdf-highlighter-plus";
|
} from "react-pdf-highlighter-plus";
|
||||||
|
// pdfjs-dist's own pdf_viewer.css is the authoritative source for
|
||||||
|
// text-layer positioning. The version bundled with
|
||||||
|
// react-pdf-highlighter-plus is a minimal *override* (missing
|
||||||
|
// `position: absolute`, `inset: 0`, and PDF.js 4.x's
|
||||||
|
// `--scale-factor` handling) — load the real one first, then the
|
||||||
|
// library's overrides on top.
|
||||||
|
import "pdfjs-dist/web/pdf_viewer.css";
|
||||||
import "react-pdf-highlighter-plus/style/style.css";
|
import "react-pdf-highlighter-plus/style/style.css";
|
||||||
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
|
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
|
||||||
|
import "./highlight-styles.css";
|
||||||
|
import "./debug-textlayer.css";
|
||||||
|
|
||||||
import type { NormalizedRect, Selector } from "@shared/selector";
|
import type { NormalizedRect, Selector } from "@shared/selector";
|
||||||
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
|
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
|
||||||
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
|
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
|
||||||
|
import { runScrollToHighlightJob } from "./scroll-job";
|
||||||
|
|
||||||
export { selectorsFromPdfCapture };
|
export { selectorsFromPdfCapture };
|
||||||
|
|
||||||
@@ -106,20 +124,33 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ActiveAnnotationContext = createContext<string | null | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trivial container that renders every stored highlight as a TextHighlight.
|
* Stable highlight row — component type never changes so PdfHighlighter does
|
||||||
* For the spike, no editing tooling — just visual proof of "did the saved
|
* not remount highlight layers on activation changes (which disturbs scroll).
|
||||||
* coordinates land on the right passage on the right page after reload?"
|
* Active/focus styling reads from context instead.
|
||||||
*/
|
*/
|
||||||
function SpikeHighlightContainer(): ReactNode {
|
function SpikeHighlightContainer(): ReactNode {
|
||||||
|
const activeAnnotationId = useContext(ActiveAnnotationContext);
|
||||||
|
const onHighlightClicked = useContext(HighlightClickContext);
|
||||||
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
||||||
// Wrap the highlight in a data-tagged container so the visual-guide
|
const isActive = activeAnnotationId === highlight.id;
|
||||||
// overlay's HighlightRectBridge can locate it via DOM query. The
|
|
||||||
// wrapper uses display: contents so it doesn't affect layout — the
|
|
||||||
// bounding rect is gathered from the live TextHighlight children at
|
|
||||||
// query time.
|
|
||||||
return (
|
return (
|
||||||
<div data-highlight-id={highlight.id} style={{ display: "contents" }}>
|
<div
|
||||||
|
data-highlight-id={highlight.id}
|
||||||
|
data-ce-active={isActive ? "true" : "false"}
|
||||||
|
style={{ display: "contents" }}
|
||||||
|
onClickCapture={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onHighlightClicked?.(highlight.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<MonitoredHighlightContainer>
|
<MonitoredHighlightContainer>
|
||||||
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
||||||
</MonitoredHighlightContainer>
|
</MonitoredHighlightContainer>
|
||||||
@@ -166,6 +197,66 @@ export interface PdfSpikeViewerProps {
|
|||||||
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
|
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
|
||||||
/** Annotation id to scroll to and highlight on mount, if any. */
|
/** Annotation id to scroll to and highlight on mount, if any. */
|
||||||
readonly scrollToAnnotationId?: string;
|
readonly scrollToAnnotationId?: string;
|
||||||
|
/**
|
||||||
|
* Bumps when the same annotation should be re-scrolled (e.g. repeat click).
|
||||||
|
* Format is opaque — typically `${annotationId}:${version}`.
|
||||||
|
*/
|
||||||
|
readonly scrollRequestKey?: string;
|
||||||
|
/**
|
||||||
|
* Annotation id currently focused. The matching highlight gets a
|
||||||
|
* thicker border (see highlight-styles.css). `null`/undefined means
|
||||||
|
* "no active highlight".
|
||||||
|
*/
|
||||||
|
readonly activeAnnotationId?: string | null;
|
||||||
|
/**
|
||||||
|
* Called when the user clicks an existing highlight in the page.
|
||||||
|
* The receiver typically activates the matching evidence item.
|
||||||
|
*/
|
||||||
|
onHighlightClicked?(annotationId: string): void;
|
||||||
|
/**
|
||||||
|
* When true, paint the PDF text-layer spans in light grey so it's
|
||||||
|
* obvious which glyphs have a selectable text overlay and which are
|
||||||
|
* image-only. Also logs every onSelection event to the console.
|
||||||
|
*/
|
||||||
|
readonly debugTextLayer?: boolean;
|
||||||
|
/**
|
||||||
|
* Hide specific PDF.js layers so you can see what sits underneath.
|
||||||
|
* Helps diagnose layer-stacking issues (e.g. "is the text layer
|
||||||
|
* covering the canvas content?").
|
||||||
|
*/
|
||||||
|
readonly hideCanvas?: boolean;
|
||||||
|
readonly hideTextLayer?: boolean;
|
||||||
|
readonly hideAnnotationLayer?: boolean;
|
||||||
|
readonly hideXfaLayer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nudge the PDF scroll container so `highlight` sits vertically centred.
|
||||||
|
* Best-effort: depends on highlight layer DOM being present after scroll.
|
||||||
|
*/
|
||||||
|
function centerHighlightInViewer(
|
||||||
|
utils: PdfHighlighterUtils,
|
||||||
|
highlight: Highlight,
|
||||||
|
attempt = 0,
|
||||||
|
): void {
|
||||||
|
const viewer = utils.getViewer();
|
||||||
|
const container = viewer?.container as HTMLElement | undefined;
|
||||||
|
if (!container) return;
|
||||||
|
const rect = getHighlightClientRects(highlight.id);
|
||||||
|
if (!rect) {
|
||||||
|
if (attempt < 12) {
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
centerHighlightInViewer(utils, highlight, attempt + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cRect = container.getBoundingClientRect();
|
||||||
|
const highlightCenterY = rect.top + rect.height / 2;
|
||||||
|
const containerCenterY = cRect.top + cRect.height / 2;
|
||||||
|
const delta = highlightCenterY - containerCenterY;
|
||||||
|
if (Math.abs(delta) < 4) return;
|
||||||
|
container.scrollTop += delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredAnnotation {
|
export interface StoredAnnotation {
|
||||||
@@ -181,47 +272,151 @@ export interface StoredAnnotation {
|
|||||||
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
|
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
|
||||||
*/
|
*/
|
||||||
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||||
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId } = props;
|
const {
|
||||||
|
pdfUrl,
|
||||||
|
storedAnnotations,
|
||||||
|
onSelectionCaptured,
|
||||||
|
scrollToAnnotationId,
|
||||||
|
scrollRequestKey,
|
||||||
|
activeAnnotationId,
|
||||||
|
onHighlightClicked,
|
||||||
|
debugTextLayer,
|
||||||
|
hideCanvas,
|
||||||
|
hideTextLayer,
|
||||||
|
hideAnnotationLayer,
|
||||||
|
hideXfaLayer,
|
||||||
|
} = props;
|
||||||
|
const onHighlightClickedRef = useRef(onHighlightClicked);
|
||||||
|
onHighlightClickedRef.current = onHighlightClicked;
|
||||||
|
const handleHighlightClicked = useCallback((annotationId: string) => {
|
||||||
|
onHighlightClickedRef.current?.(annotationId);
|
||||||
|
}, []);
|
||||||
|
const pdfLoaderDocument = useMemo(
|
||||||
|
() => ({
|
||||||
|
url: pdfUrl,
|
||||||
|
// PdfLoader's effect depends on `document` by reference — must be
|
||||||
|
// stable across re-renders or the PDF reloads and scroll resets to top.
|
||||||
|
cMapUrl: "/cmaps/",
|
||||||
|
cMapPacked: true,
|
||||||
|
standardFontDataUrl: "/standard_fonts/",
|
||||||
|
}),
|
||||||
|
[pdfUrl],
|
||||||
|
);
|
||||||
|
const wrapperClasses = [
|
||||||
|
debugTextLayer ? "ce-debug-textlayer" : null,
|
||||||
|
hideCanvas ? "ce-hide-canvas" : null,
|
||||||
|
hideTextLayer ? "ce-hide-text-layer" : null,
|
||||||
|
hideAnnotationLayer ? "ce-hide-annotation-layer" : null,
|
||||||
|
hideXfaLayer ? "ce-hide-xfa-layer" : null,
|
||||||
|
]
|
||||||
|
.filter((c): c is string => c !== null)
|
||||||
|
.join(" ");
|
||||||
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
||||||
const [didScroll, setDidScroll] = useState<string | null>(null);
|
const scrollStateRef = useRef({ lastCompletedKey: null as string | null });
|
||||||
|
|
||||||
const highlights = useMemo<Highlight[]>(() => {
|
const highlights = useMemo<Highlight[]>(() => {
|
||||||
const out: Highlight[] = [];
|
const out: Highlight[] = [];
|
||||||
|
const skipped: { id: string; reason: string }[] = [];
|
||||||
for (const a of storedAnnotations) {
|
for (const a of storedAnnotations) {
|
||||||
const h = highlightFromSelectors(a.id, a.text, a.selectors);
|
const h = highlightFromSelectors(a.id, a.text, a.selectors);
|
||||||
if (h) out.push(h);
|
if (h) out.push(h);
|
||||||
|
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
|
||||||
|
}
|
||||||
|
if (debugTextLayer) {
|
||||||
|
console.log("[ce] viewer highlights", {
|
||||||
|
in: storedAnnotations.length,
|
||||||
|
rendered: out.length,
|
||||||
|
rendered_detail: out.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
page: h.position.boundingRect.pageNumber,
|
||||||
|
bounding: h.position.boundingRect,
|
||||||
|
rectCount: h.position.rects.length,
|
||||||
|
})),
|
||||||
|
skipped,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [storedAnnotations]);
|
}, [storedAnnotations, debugTextLayer]);
|
||||||
|
|
||||||
|
const highlightsRef = useRef(highlights);
|
||||||
|
highlightsRef.current = highlights;
|
||||||
|
|
||||||
|
const highlightsSignature = useMemo(
|
||||||
|
() => highlights.map((h) => h.id).join(","),
|
||||||
|
[highlights],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-render highlight layers when focus moves so `data-ce-active` updates.
|
||||||
|
const highlightsForViewer = useMemo(
|
||||||
|
() => highlights,
|
||||||
|
[highlights, activeAnnotationId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
|
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
|
||||||
const utils = utilsRef.current;
|
if (!requestKey || !scrollToAnnotationId) return;
|
||||||
const target = highlights.find((h) => h.id === scrollToAnnotationId);
|
if (scrollStateRef.current.lastCompletedKey === requestKey) return;
|
||||||
if (!utils || !target) return;
|
|
||||||
utils.scrollToHighlight(target);
|
if (debugTextLayer) {
|
||||||
setDidScroll(scrollToAnnotationId);
|
console.log("[ce] scrollToAnnotation requested", {
|
||||||
}, [scrollToAnnotationId, highlights, didScroll]);
|
id: scrollToAnnotationId,
|
||||||
|
requestKey,
|
||||||
|
utilsAvailable: !!utilsRef.current,
|
||||||
|
targetFound: !!highlightsRef.current.find((h) => h.id === scrollToAnnotationId),
|
||||||
|
knownIds: highlightsRef.current.map((h) => h.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return runScrollToHighlightJob(
|
||||||
|
{ requestKey, annotationId: scrollToAnnotationId },
|
||||||
|
{
|
||||||
|
getUtils: () => utilsRef.current,
|
||||||
|
findHighlight: (id) => highlightsRef.current.find((h) => h.id === id),
|
||||||
|
scrollToHighlight: (utils, target) => utils.scrollToHighlight(target),
|
||||||
|
centerHighlight: (utils, target) => centerHighlightInViewer(utils, target),
|
||||||
|
scheduleFrame: (fn) => requestAnimationFrame(fn),
|
||||||
|
},
|
||||||
|
scrollStateRef.current,
|
||||||
|
);
|
||||||
|
}, [scrollToAnnotationId, scrollRequestKey, highlightsSignature, debugTextLayer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PdfLoader document={pdfUrl}>
|
<div
|
||||||
|
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
|
||||||
|
style={{ height: "100%" }}
|
||||||
|
>
|
||||||
|
<PdfLoader document={pdfLoaderDocument}>
|
||||||
{(pdfDocument) => (
|
{(pdfDocument) => (
|
||||||
|
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
|
||||||
|
<HighlightClickContext.Provider value={handleHighlightClicked}>
|
||||||
<PdfHighlighter
|
<PdfHighlighter
|
||||||
pdfDocument={pdfDocument}
|
pdfDocument={pdfDocument}
|
||||||
highlights={highlights}
|
highlights={highlightsForViewer}
|
||||||
utilsRef={(u) => {
|
utilsRef={(u) => {
|
||||||
utilsRef.current = u;
|
utilsRef.current = u;
|
||||||
}}
|
}}
|
||||||
onSelection={(selection) => {
|
onSelection={(selection) => {
|
||||||
const capture = captureFromPdfSelection(selection);
|
const capture = captureFromPdfSelection(selection);
|
||||||
const selectors = selectorsFromPdfCapture(capture);
|
const selectors = selectorsFromPdfCapture(capture);
|
||||||
|
if (debugTextLayer) {
|
||||||
|
console.log("[ce] onSelection", {
|
||||||
|
text: capture.text,
|
||||||
|
page: capture.page,
|
||||||
|
rects: capture.rects,
|
||||||
|
selectorTypes: selectors.map((s) => s.type),
|
||||||
|
raw: selection,
|
||||||
|
});
|
||||||
|
}
|
||||||
onSelectionCaptured(capture, selectors);
|
onSelectionCaptured(capture, selectors);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SpikeHighlightContainer />
|
<SpikeHighlightContainer />
|
||||||
</PdfHighlighter>
|
</PdfHighlighter>
|
||||||
|
</HighlightClickContext.Provider>
|
||||||
|
</ActiveAnnotationContext.Provider>
|
||||||
)}
|
)}
|
||||||
</PdfLoader>
|
</PdfLoader>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
EngineProvider,
|
EngineProvider,
|
||||||
SessionProvider,
|
SessionProvider,
|
||||||
useActiveSession,
|
useActiveSession,
|
||||||
|
useActiveSessionId,
|
||||||
useEngine,
|
useEngine,
|
||||||
usePdfByteStore,
|
usePdfByteStore,
|
||||||
useSessionByteStoreRegistry,
|
useSessionByteStoreRegistry,
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
useSessionVersionBumper,
|
useSessionVersionBumper,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
|
import { CaptureLinkPersister } from "./forms/CaptureLinkPersister";
|
||||||
|
import { loadCaptureState } from "./forms/capture-persistence";
|
||||||
import { FormsApp } from "./forms/FormsApp";
|
import { FormsApp } from "./forms/FormsApp";
|
||||||
import { ReviewLayout } from "./ReviewLayout";
|
import { ReviewLayout } from "./ReviewLayout";
|
||||||
|
|
||||||
@@ -158,9 +161,35 @@ function ActiveAppFrame({
|
|||||||
|
|
||||||
function SessionScopedTree({ mode }: { mode: AppMode }) {
|
function SessionScopedTree({ mode }: { mode: AppMode }) {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
|
const sessionId = useActiveSessionId();
|
||||||
|
const restoredCapture = useMemo(
|
||||||
|
() => (sessionId ? loadCaptureState(sessionId) : null),
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BinderProvider bus={engine.bus}>
|
<BinderProvider
|
||||||
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />}
|
bus={engine.bus}
|
||||||
|
{...(restoredCapture?.evidenceLinks
|
||||||
|
? { initialLinks: restoredCapture.evidenceLinks }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
<CaptureLinkPersister sessionId={sessionId} />
|
||||||
|
{mode === "forms" ? (
|
||||||
|
<FormsApp
|
||||||
|
sessionId={sessionId}
|
||||||
|
{...(restoredCapture?.formSchema
|
||||||
|
? { initialSchema: restoredCapture.formSchema }
|
||||||
|
: {})}
|
||||||
|
{...(restoredCapture?.fieldValues
|
||||||
|
? { initialFieldValues: restoredCapture.fieldValues }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReviewLayout upload={<UploadDropzone />} />
|
||||||
|
)}
|
||||||
</BinderProvider>
|
</BinderProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,7 +317,7 @@ function ActiveTopBar({
|
|||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ id: "review" as const, label: "Review" },
|
{ id: "review" as const, label: "Review" },
|
||||||
{ id: "forms" as const, label: "Forms" },
|
{ id: "forms" as const, label: "Capture" },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,33 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* FormsApp — the evidence-backed form mode for CE-WP-0003.
|
* FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007).
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout (CE-WP-0007):
|
||||||
*
|
*
|
||||||
* ┌────────────┬─────────────────┬─────────────┐
|
* ┌────────────┬─────────────────┬─────────────┐
|
||||||
* │ Collection │ FormRenderer │ ViewerShell │
|
* │ Collection │ ViewerShell │ FormPane │
|
||||||
* │ │ (left) │ (right) │
|
|
||||||
* ├────────────┴─────────────────┴─────────────┤
|
* ├────────────┴─────────────────┴─────────────┤
|
||||||
* │ EvidenceStrip (bottom) │
|
* │ EvidenceStrip (bottom) │
|
||||||
* └────────────────────────────────────────────┘
|
* └────────────────────────────────────────────┘
|
||||||
*
|
*
|
||||||
* Linking interaction (T05):
|
* Linking: field must have focus; clicking evidence links directly.
|
||||||
* 1. User clicks an evidence card in the strip → it becomes "selected
|
|
||||||
* for linking" (highlighted; banner appears in the form pane).
|
|
||||||
* 2. User then clicks a form field. The field's `FormFieldActivated`
|
|
||||||
* event triggers `bindings.linkEvidenceToTarget(selected, field)`.
|
|
||||||
* 3. The selected-for-linking state clears; the field's link count
|
|
||||||
* chip increments.
|
|
||||||
*
|
|
||||||
* Active-evidence cycling (T06) and the visual guide overlay (T07) build
|
|
||||||
* on this composition without changing the link interaction.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
|
import type { EvidenceLink } from "@shared/evidence-link";
|
||||||
|
import type { EvidenceItemId } from "@shared/ids";
|
||||||
|
|
||||||
import { Overlay, useActiveState, useBinder } from "@binder/index";
|
import {
|
||||||
|
Overlay,
|
||||||
|
useActiveState,
|
||||||
|
useBinder,
|
||||||
|
useRegisterRect,
|
||||||
|
} from "@binder/index";
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
|
||||||
|
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
||||||
import {
|
import {
|
||||||
CollectionList,
|
CollectionList,
|
||||||
ViewerShell,
|
ViewerShell,
|
||||||
@@ -37,21 +36,133 @@ import {
|
|||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
import { FormRenderer } from "@binder/FormRenderer";
|
import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer";
|
||||||
|
|
||||||
import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips";
|
import { persistCapturePatch } from "./capture-persistence";
|
||||||
import { DEMO_SCHEMA } from "./demo-schema";
|
import { DEMO_SCHEMA } from "./demo-schema";
|
||||||
import { HighlightRectBridge } from "./HighlightRectBridge";
|
import { HighlightRectBridge } from "./HighlightRectBridge";
|
||||||
|
|
||||||
export function FormsApp() {
|
export type EvidenceStripFilter = "all" | "attached";
|
||||||
|
|
||||||
|
const STRIP_FILTER_EVENT = "citation-evidence:strip-filter";
|
||||||
|
|
||||||
|
function publishStripFilter(mode: EvidenceStripFilter) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePreview(text: string, max = 80): string {
|
||||||
|
const t = text.trim();
|
||||||
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormsAppProps {
|
||||||
|
readonly sessionId: SessionId;
|
||||||
|
readonly initialSchema?: FormSchema;
|
||||||
|
readonly initialFieldValues?: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormsApp({
|
||||||
|
sessionId,
|
||||||
|
initialSchema,
|
||||||
|
initialFieldValues,
|
||||||
|
}: FormsAppProps) {
|
||||||
|
const [schema, setSchema] = useState<FormSchema>(() =>
|
||||||
|
initialSchema
|
||||||
|
? { ...initialSchema, fields: [...initialSchema.fields] }
|
||||||
|
: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldLabels = useMemo(
|
||||||
|
() => new Map(schema.fields.map((f) => [f.id, f.label] as const)),
|
||||||
|
[schema],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [fieldValues, setFieldValues] = useState<Record<string, string>>(
|
||||||
|
() => ({ ...(initialFieldValues ?? {}) }),
|
||||||
|
);
|
||||||
|
const [showAddFieldForm, setShowAddFieldForm] = useState(false);
|
||||||
|
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
|
||||||
|
setFieldValues((prev) => ({ ...prev, [fieldId]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
persistCapturePatch(sessionId, { formSchema: schema, fieldValues });
|
||||||
|
}, [sessionId, schema, fieldValues]);
|
||||||
|
|
||||||
|
const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => {
|
||||||
|
let max = 0;
|
||||||
|
for (const f of fields) {
|
||||||
|
const m = /^field_(\d+)$/.exec(f.id);
|
||||||
|
if (m) max = Math.max(max, Number(m[1]));
|
||||||
|
}
|
||||||
|
return `field_${max + 1}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmAddField = useCallback(
|
||||||
|
(patch: FieldDefinitionPatch) => {
|
||||||
|
setSchema((prev) => {
|
||||||
|
const id = nextFieldId(prev.fields);
|
||||||
|
const n = prev.fields.length + 1;
|
||||||
|
const field: FormFieldSchema = {
|
||||||
|
id,
|
||||||
|
type: patch.type,
|
||||||
|
label: patch.label.length > 0 ? patch.label : `New field ${n}`,
|
||||||
|
};
|
||||||
|
return { ...prev, fields: [...prev.fields, field] };
|
||||||
|
});
|
||||||
|
setShowAddFieldForm(false);
|
||||||
|
},
|
||||||
|
[nextFieldId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveFieldEdit = useCallback(
|
||||||
|
(fieldId: string, patch: FieldDefinitionPatch) => {
|
||||||
|
setSchema((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fields: prev.fields.map((f) =>
|
||||||
|
f.id === fieldId
|
||||||
|
? {
|
||||||
|
...f,
|
||||||
|
type: patch.type,
|
||||||
|
label: patch.label.length > 0 ? patch.label : f.label,
|
||||||
|
}
|
||||||
|
: f,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
setEditingFieldId(null);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||||
<CollectionList />
|
<CollectionList />
|
||||||
<FormPane />
|
|
||||||
<ViewerShell />
|
<ViewerShell />
|
||||||
|
<FormPane
|
||||||
|
schema={schema}
|
||||||
|
fieldValues={fieldValues}
|
||||||
|
onFieldValueChange={handleFieldValueChange}
|
||||||
|
showAddFieldForm={showAddFieldForm}
|
||||||
|
editingFieldId={editingFieldId}
|
||||||
|
onRequestAddField={() => {
|
||||||
|
setEditingFieldId(null);
|
||||||
|
setShowAddFieldForm(true);
|
||||||
|
}}
|
||||||
|
onConfirmAddField={handleConfirmAddField}
|
||||||
|
onCancelAddField={() => setShowAddFieldForm(false)}
|
||||||
|
onBeginEditField={(fieldId) => {
|
||||||
|
setShowAddFieldForm(false);
|
||||||
|
setEditingFieldId(fieldId);
|
||||||
|
}}
|
||||||
|
onSaveFieldEdit={handleSaveFieldEdit}
|
||||||
|
onCancelFieldEdit={() => setEditingFieldId(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<EvidenceStrip />
|
<EvidenceStrip fieldLabels={fieldLabels} />
|
||||||
<ScrollBridge />
|
<ScrollBridge />
|
||||||
<HighlightRectBridge />
|
<HighlightRectBridge />
|
||||||
<Overlay />
|
<Overlay />
|
||||||
@@ -59,12 +170,6 @@ export function FormsApp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge: the binder's active-state machine doesn't know how to scroll
|
|
||||||
* the viewer; the work-level `useScrollToAnnotation` does. This subscriber
|
|
||||||
* reads `activeAnnotationId` from the binder and calls `scrollTo` whenever
|
|
||||||
* it changes. Lives in app/ because that's where both subsystems meet.
|
|
||||||
*/
|
|
||||||
function ScrollBridge() {
|
function ScrollBridge() {
|
||||||
const { state } = useActiveState();
|
const { state } = useActiveState();
|
||||||
const { scrollTo } = useScrollToAnnotation();
|
const { scrollTo } = useScrollToAnnotation();
|
||||||
@@ -76,88 +181,125 @@ function ScrollBridge() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormPane() {
|
function FormPane({
|
||||||
|
schema,
|
||||||
|
fieldValues,
|
||||||
|
onFieldValueChange,
|
||||||
|
showAddFieldForm,
|
||||||
|
editingFieldId,
|
||||||
|
onRequestAddField,
|
||||||
|
onConfirmAddField,
|
||||||
|
onCancelAddField,
|
||||||
|
onBeginEditField,
|
||||||
|
onSaveFieldEdit,
|
||||||
|
onCancelFieldEdit,
|
||||||
|
}: {
|
||||||
|
schema: FormSchema;
|
||||||
|
fieldValues: Readonly<Record<string, string>>;
|
||||||
|
onFieldValueChange: (fieldId: string, value: string) => void;
|
||||||
|
showAddFieldForm: boolean;
|
||||||
|
editingFieldId: string | null;
|
||||||
|
onRequestAddField: () => void;
|
||||||
|
onConfirmAddField: (patch: FieldDefinitionPatch) => void;
|
||||||
|
onCancelAddField: () => void;
|
||||||
|
onBeginEditField: (fieldId: string) => void;
|
||||||
|
onSaveFieldEdit: (fieldId: string, patch: FieldDefinitionPatch) => void;
|
||||||
|
onCancelFieldEdit: () => void;
|
||||||
|
}) {
|
||||||
const { document } = useActiveDocument();
|
const { document } = useActiveDocument();
|
||||||
const { bindings } = useBinder();
|
const { bindings } = useBinder();
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
||||||
const { state: activeState } = useActiveState();
|
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
||||||
|
const { state: activeState, setActiveEvidence } = useActiveState();
|
||||||
|
|
||||||
const [selectedForLinking, setSelectedForLinking] = useState<EvidenceItemId | null>(
|
useEffect(() => {
|
||||||
null,
|
return engine.bus.on("FormFieldActivated", () => {
|
||||||
);
|
publishStripFilter("attached");
|
||||||
|
});
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = activeState.activeTarget;
|
||||||
|
if (!target || activeState.activeEvidenceItemId) return;
|
||||||
|
const links = bindings.listEvidenceForTarget(target);
|
||||||
|
if (links.length === 0) return;
|
||||||
|
const item = engine.evidence.get(links[0]!.evidenceItemId);
|
||||||
|
if (!item) return;
|
||||||
|
setActiveEvidence(item.id, item.annotationIds[0] ?? null);
|
||||||
|
}, [
|
||||||
|
activeState.activeTarget,
|
||||||
|
activeState.activeEvidenceItemId,
|
||||||
|
bindings,
|
||||||
|
engine,
|
||||||
|
linkTick,
|
||||||
|
unlinkTick,
|
||||||
|
setActiveEvidence,
|
||||||
|
]);
|
||||||
|
|
||||||
// Compute per-field link counts. Re-derives on link create.
|
|
||||||
const linkCounts = useMemo<Record<string, number>>(() => {
|
const linkCounts = useMemo<Record<string, number>>(() => {
|
||||||
const out: Record<string, number> = {};
|
const out: Record<string, number> = {};
|
||||||
for (const field of DEMO_SCHEMA.fields) {
|
for (const field of schema.fields) {
|
||||||
out[field.id] = bindings.listEvidenceForTarget({
|
out[field.id] = bindings.listEvidenceForTarget({
|
||||||
targetType: "form-field",
|
targetType: "form-field",
|
||||||
targetId: field.id,
|
targetId: field.id,
|
||||||
}).length;
|
}).length;
|
||||||
}
|
}
|
||||||
void linkTick;
|
void linkTick;
|
||||||
|
void unlinkTick;
|
||||||
return out;
|
return out;
|
||||||
}, [bindings, linkTick]);
|
}, [schema.fields, bindings, linkTick, unlinkTick]);
|
||||||
|
|
||||||
// Compute chip items for the currently-active target.
|
const linkHints = useMemo<Record<string, string>>(() => {
|
||||||
const activeChipItems = useMemo<readonly ActiveEvidenceChipsItem[]>(() => {
|
const out: Record<string, string> = {};
|
||||||
if (!activeState.activeTarget) return [];
|
for (const field of schema.fields) {
|
||||||
const links = bindings.listEvidenceForTarget(activeState.activeTarget);
|
const links = bindings.listEvidenceForTarget({
|
||||||
return links
|
targetType: "form-field",
|
||||||
.map((link): ActiveEvidenceChipsItem | null => {
|
targetId: field.id,
|
||||||
const item = engine.evidence.get(link.evidenceItemId);
|
|
||||||
if (!item) return null;
|
|
||||||
const annotationId: AnnotationId | null = item.annotationIds[0] ?? null;
|
|
||||||
const annotation = annotationId ? engine.annotations.get(annotationId) : null;
|
|
||||||
return {
|
|
||||||
evidenceItemId: link.evidenceItemId,
|
|
||||||
annotationId,
|
|
||||||
quote: annotation?.quote ?? "(no quote)",
|
|
||||||
...(item.commentary ? { commentary: item.commentary } : {}),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((c): c is ActiveEvidenceChipsItem => c !== null);
|
|
||||||
// linkTick is included so newly created links populate the chips
|
|
||||||
// without an explicit refresh.
|
|
||||||
}, [activeState.activeTarget, bindings, engine, linkTick]);
|
|
||||||
|
|
||||||
// Listen for FormFieldActivated and, if an evidence is staged, create
|
|
||||||
// the link. The state machine reduction (focus-target) happens in
|
|
||||||
// parallel via ActiveStateProvider's own handler.
|
|
||||||
useEffect(() => {
|
|
||||||
return engine.bus.on("FormFieldActivated", (event) => {
|
|
||||||
if (!selectedForLinking) return;
|
|
||||||
bindings.linkEvidenceToTarget({
|
|
||||||
evidenceItemId: selectedForLinking,
|
|
||||||
target: event.target,
|
|
||||||
});
|
});
|
||||||
setSelectedForLinking(null);
|
if (links.length === 0) continue;
|
||||||
});
|
const item = engine.evidence.get(links[0]!.evidenceItemId);
|
||||||
}, [engine, bindings, selectedForLinking]);
|
const ann = item?.annotationIds[0]
|
||||||
|
? engine.annotations.get(item.annotationIds[0])
|
||||||
|
: null;
|
||||||
|
const quote = ann?.quote ?? item?.commentary ?? "";
|
||||||
|
if (quote) out[field.id] = quotePreview(quote);
|
||||||
|
}
|
||||||
|
void linkTick;
|
||||||
|
void unlinkTick;
|
||||||
|
return out;
|
||||||
|
}, [schema.fields, bindings, engine, linkTick, unlinkTick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 0",
|
flex: "0 0 320px",
|
||||||
minWidth: 320,
|
minWidth: 320,
|
||||||
borderRight: "1px solid #ddd",
|
borderLeft: "1px solid #ddd",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectedBanner
|
|
||||||
selectedForLinking={selectedForLinking}
|
|
||||||
onClear={() => setSelectedForLinking(null)}
|
|
||||||
/>
|
|
||||||
{document ? (
|
{document ? (
|
||||||
<>
|
<FormRenderer
|
||||||
<FormRenderer schema={DEMO_SCHEMA} linkCounts={linkCounts} />
|
schema={schema}
|
||||||
<ActiveEvidenceChips items={activeChipItems} />
|
values={fieldValues}
|
||||||
</>
|
onValueChange={onFieldValueChange}
|
||||||
|
linkCounts={linkCounts}
|
||||||
|
linkHints={linkHints}
|
||||||
|
showAddFieldForm={showAddFieldForm}
|
||||||
|
onRequestAddField={onRequestAddField}
|
||||||
|
onConfirmAddField={onConfirmAddField}
|
||||||
|
onCancelAddField={onCancelAddField}
|
||||||
|
editingFieldId={editingFieldId}
|
||||||
|
onBeginEditField={onBeginEditField}
|
||||||
|
onSaveFieldEdit={onSaveFieldEdit}
|
||||||
|
onCancelFieldEdit={onCancelFieldEdit}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyHint />
|
<EmptyHint />
|
||||||
)}
|
)}
|
||||||
<SelectionContext setSelected={setSelectedForLinking} />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,97 +307,114 @@ function FormPane() {
|
|||||||
function EmptyHint() {
|
function EmptyHint() {
|
||||||
return (
|
return (
|
||||||
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
|
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
|
||||||
Pick a fixture from the collection list to start binding evidence.
|
Pick a document from the collection to start capturing evidence links.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectedBanner({
|
function EvidenceStrip({
|
||||||
selectedForLinking,
|
fieldLabels,
|
||||||
onClear,
|
|
||||||
}: {
|
}: {
|
||||||
selectedForLinking: EvidenceItemId | null;
|
fieldLabels: ReadonlyMap<string, string>;
|
||||||
onClear: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
if (!selectedForLinking) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
background: "#fff4d6",
|
|
||||||
borderBottom: "1px solid #f0c040",
|
|
||||||
fontFamily: "system-ui, sans-serif",
|
|
||||||
fontSize: 12,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Evidence staged for linking. Click a form field to link it, or{" "}
|
|
||||||
</span>
|
|
||||||
<button onClick={onClear} style={{ fontSize: 12, padding: "2px 8px" }}>
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridges the strip's "stage this evidence" callback to the FormPane's
|
|
||||||
* local state. The strip lives in a sibling DOM subtree; rather than
|
|
||||||
* lifting `selectedForLinking` all the way up to FormsApp, we publish a
|
|
||||||
* setter into a module-scoped event target.
|
|
||||||
*
|
|
||||||
* Simpler than another context for one local handshake.
|
|
||||||
*/
|
|
||||||
const STAGED_EVENT = "citation-evidence:staged-for-linking";
|
|
||||||
|
|
||||||
function SelectionContext({
|
|
||||||
setSelected,
|
|
||||||
}: {
|
|
||||||
setSelected: (id: EvidenceItemId | null) => void;
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent<EvidenceItemId | null>).detail;
|
|
||||||
setSelected(detail);
|
|
||||||
};
|
|
||||||
window.addEventListener(STAGED_EVENT, handler);
|
|
||||||
return () => window.removeEventListener(STAGED_EVENT, handler);
|
|
||||||
}, [setSelected]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function publishStagedForLinking(id: EvidenceItemId | null) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function EvidenceStrip() {
|
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
|
const { bindings } = useBinder();
|
||||||
const { document } = useActiveDocument();
|
const { document } = useActiveDocument();
|
||||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||||
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
||||||
const { state: activeState } = useActiveState();
|
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
||||||
const [stagedId, setStagedId] = useState<EvidenceItemId | null>(null);
|
const { state: activeState, setActiveEvidence, clearActiveEvidence } =
|
||||||
|
useActiveState();
|
||||||
|
|
||||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
const [userFilter, setUserFilter] = useState<EvidenceStripFilter>("all");
|
||||||
|
const [sessionFilter, setSessionFilter] = useState<EvidenceStripFilter | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveFilter = sessionFilter ?? userFilter;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
setSessionFilter((e as CustomEvent<EvidenceStripFilter>).detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(STRIP_FILTER_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(STRIP_FILTER_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeState.activeTarget) {
|
||||||
|
setSessionFilter(null);
|
||||||
|
}
|
||||||
|
}, [activeState.activeTarget]);
|
||||||
|
|
||||||
|
const allItems = useMemo<readonly EvidenceItem[]>(() => {
|
||||||
if (!document) return [];
|
if (!document) return [];
|
||||||
void createTick;
|
void createTick;
|
||||||
void updateTick;
|
void updateTick;
|
||||||
void linkTick;
|
void linkTick;
|
||||||
|
void unlinkTick;
|
||||||
return engine.evidence.listByDocument(document.id);
|
return engine.evidence.listByDocument(document.id);
|
||||||
}, [document, engine, createTick, updateTick, linkTick]);
|
}, [document, engine, createTick, updateTick, linkTick, unlinkTick]);
|
||||||
|
|
||||||
const handleStage = (id: EvidenceItemId) => {
|
const items = useMemo(() => {
|
||||||
const next = stagedId === id ? null : id;
|
if (effectiveFilter !== "attached" || !activeState.activeTarget) {
|
||||||
setStagedId(next);
|
return allItems;
|
||||||
publishStagedForLinking(next);
|
}
|
||||||
};
|
const links = bindings.listEvidenceForTarget(activeState.activeTarget);
|
||||||
|
const ids = new Set(links.map((l) => l.evidenceItemId));
|
||||||
|
const attached = allItems.filter((item) => ids.has(item.id));
|
||||||
|
return attached.length > 0 ? attached : allItems;
|
||||||
|
}, [
|
||||||
|
allItems,
|
||||||
|
effectiveFilter,
|
||||||
|
activeState.activeTarget,
|
||||||
|
bindings,
|
||||||
|
linkTick,
|
||||||
|
unlinkTick,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tryLink = useCallback(
|
||||||
|
(evidenceItemId: EvidenceItemId, fieldId: string): boolean => {
|
||||||
|
const existing = bindings
|
||||||
|
.listEvidenceForTarget({ targetType: "form-field", targetId: fieldId })
|
||||||
|
.some((l) => l.evidenceItemId === evidenceItemId);
|
||||||
|
if (existing) return false;
|
||||||
|
bindings.linkEvidenceToTarget({
|
||||||
|
evidenceItemId,
|
||||||
|
target: { targetType: "form-field", targetId: fieldId },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[bindings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(item: EvidenceItem) => {
|
||||||
|
const annId = item.annotationIds[0] ?? null;
|
||||||
|
setActiveEvidence(item.id, annId);
|
||||||
|
|
||||||
|
const target = activeState.activeTarget;
|
||||||
|
if (target?.targetType === "form-field") {
|
||||||
|
tryLink(item.id, target.targetId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeState.activeTarget, setActiveEvidence, tryLink],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnlink = useCallback(
|
||||||
|
(link: EvidenceLink) => {
|
||||||
|
bindings.unlinkEvidence(link.id);
|
||||||
|
if (
|
||||||
|
activeState.activeEvidenceItemId === link.evidenceItemId &&
|
||||||
|
activeState.activeTarget?.targetType === link.targetType &&
|
||||||
|
activeState.activeTarget?.targetId === link.targetId
|
||||||
|
) {
|
||||||
|
clearActiveEvidence();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bindings, activeState, clearActiveEvidence],
|
||||||
|
);
|
||||||
|
|
||||||
if (!document) return null;
|
if (!document) return null;
|
||||||
|
|
||||||
@@ -267,56 +426,185 @@ function EvidenceStrip() {
|
|||||||
background: "#fafafa",
|
background: "#fafafa",
|
||||||
padding: 8,
|
padding: 8,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 8,
|
flexDirection: "column",
|
||||||
overflowX: "auto",
|
gap: 6,
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
|
||||||
|
<span style={{ color: "#666" }}>Show:</span>
|
||||||
|
<FilterToggle
|
||||||
|
label="All"
|
||||||
|
active={effectiveFilter === "all"}
|
||||||
|
onClick={() => {
|
||||||
|
setUserFilter("all");
|
||||||
|
setSessionFilter(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilterToggle
|
||||||
|
label="Linked to field"
|
||||||
|
active={effectiveFilter === "attached"}
|
||||||
|
onClick={() => {
|
||||||
|
setUserFilter("attached");
|
||||||
|
setSessionFilter(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, overflowX: "auto" }}>
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
|
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
|
||||||
No evidence yet. Switch to Review mode to capture a passage.
|
{effectiveFilter === "attached"
|
||||||
|
? "No evidence linked to the active field."
|
||||||
|
: "No evidence yet. Switch to Review mode to capture a passage."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{items.map((item) => {
|
{items.map((item) => (
|
||||||
|
<EvidenceStripCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isActive={activeState.activeEvidenceItemId === item.id}
|
||||||
|
links={bindings.listTargetsForEvidence(item.id)}
|
||||||
|
fieldLabels={fieldLabels}
|
||||||
|
onClick={() => handleCardClick(item)}
|
||||||
|
onUnlink={handleUnlink}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterToggle({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: active ? "1px solid #0050b3" : "1px solid #ccc",
|
||||||
|
background: active ? "#e8f0ff" : "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceStripCard({
|
||||||
|
item,
|
||||||
|
isActive,
|
||||||
|
links,
|
||||||
|
fieldLabels,
|
||||||
|
onClick,
|
||||||
|
onUnlink,
|
||||||
|
}: {
|
||||||
|
item: EvidenceItem;
|
||||||
|
isActive: boolean;
|
||||||
|
links: readonly EvidenceLink[];
|
||||||
|
fieldLabels: ReadonlyMap<string, string>;
|
||||||
|
onClick: () => void;
|
||||||
|
onUnlink: (link: EvidenceLink) => void;
|
||||||
|
}) {
|
||||||
|
const engine = useEngine();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useRegisterRect("evidence-card", item.id, ref);
|
||||||
|
|
||||||
const firstAnn = item.annotationIds[0]
|
const firstAnn = item.annotationIds[0]
|
||||||
? engine.annotations.get(item.annotationIds[0])
|
? engine.annotations.get(item.annotationIds[0])
|
||||||
: null;
|
: null;
|
||||||
const quote = firstAnn?.quote ?? "(no quote)";
|
const quote = firstAnn?.quote ?? "(no quote)";
|
||||||
const isStaged = stagedId === item.id;
|
|
||||||
const isActive = activeState.activeEvidenceItemId === item.id;
|
const formLinks = links.filter((l) => l.targetType === "form-field");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={item.id}
|
ref={ref}
|
||||||
onClick={() => handleStage(item.id)}
|
|
||||||
data-staged={isStaged ? "true" : "false"}
|
|
||||||
aria-current={isActive ? "true" : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
|
position: "relative",
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
maxWidth: 280,
|
maxWidth: 280,
|
||||||
textAlign: "left",
|
flexShrink: 0,
|
||||||
fontSize: 12,
|
}}
|
||||||
padding: 8,
|
>
|
||||||
border: isActive
|
{formLinks.length > 0 && (
|
||||||
? "2px solid #0050b3"
|
<div
|
||||||
: isStaged
|
style={{
|
||||||
? "2px solid #f0a000"
|
position: "absolute",
|
||||||
: "1px solid #ccc",
|
top: 4,
|
||||||
background: isActive ? "#e8f0ff" : isStaged ? "#fff4d6" : "white",
|
right: 4,
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formLinks.map((link) => {
|
||||||
|
const label = fieldLabels.get(link.targetId) ?? link.targetId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={link.id}
|
||||||
|
type="button"
|
||||||
|
title={`Linked to: ${label}. Click to remove link.`}
|
||||||
|
aria-label={`Remove link to ${label}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUnlink(link);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: "2px 4px",
|
||||||
|
border: "1px solid #88a",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "#eef",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
⧉
|
||||||
“{quote.slice(0, 100)}
|
|
||||||
{quote.length > 100 ? "…" : ""}”
|
|
||||||
</div>
|
|
||||||
{item.commentary && (
|
|
||||||
<div style={{ color: "#333" }}>{item.commentary}</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-current={isActive ? "true" : undefined}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 8,
|
||||||
|
border: isActive ? "2px solid #0050b3" : "1px solid #ccc",
|
||||||
|
background: isActive ? "#e8f0ff" : "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingRight: formLinks.length ? 24 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“{quote.slice(0, 100)}
|
||||||
|
{quote.length > 100 ? "…" : ""}”
|
||||||
|
</div>
|
||||||
|
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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));
|
||||||
|
}
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
// Vite resolves `?url` to a bundled asset URL the browser can fetch.
|
||||||
|
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
|
||||||
|
// PDF.js needs a worker URL before any PDF is parsed. Set it once at app
|
||||||
|
// bootstrap so both the source-layer ingest (extract.ts) and the viewer
|
||||||
|
// adapter (PdfSpikeViewer) can open documents.
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||||
|
|
||||||
const container = document.getElementById("root");
|
const container = document.getElementById("root");
|
||||||
if (!container) throw new Error("#root not found");
|
if (!container) throw new Error("#root not found");
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,15 @@
|
|||||||
* Inline name input + Create button. On success, navigates the hash to
|
* Inline name input + Create button. On success, navigates the hash to
|
||||||
* the new session so the rest of the app mounts. Used both on first
|
* the new session so the rest of the app mounts. Used both on first
|
||||||
* launch (no sessions yet) and after the last session was deleted.
|
* launch (no sessions yet) and after the last session was deleted.
|
||||||
|
*
|
||||||
|
* Also surfaces a "Reset all data" affordance for users who want a
|
||||||
|
* clean slate without digging into devtools — wipes every
|
||||||
|
* `citation-evidence:*` key from localStorage and reloads.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { clearAllSessionData } from "@engine/index";
|
||||||
import { useSessionService } from "@work/index";
|
import { useSessionService } from "@work/index";
|
||||||
|
|
||||||
import { navigateTo } from "./routing";
|
import { navigateTo } from "./routing";
|
||||||
@@ -16,18 +21,39 @@ export function CreateFirstSession() {
|
|||||||
const service = useSessionService();
|
const service = useSessionService();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingReset, setPendingReset] = useState(false);
|
||||||
const hasOthers = service.list().length > 0;
|
const hasOthers = service.list().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingReset) return;
|
||||||
|
const t = setTimeout(() => setPendingReset(false), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [pendingReset]);
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = service.create(name);
|
const trimmed = name.trim();
|
||||||
|
const effective = trimmed.length === 0 ? service.nextDefaultName() : name;
|
||||||
|
const created = service.create(effective);
|
||||||
navigateTo({ sessionId: created.id, mode: "review" });
|
navigateTo({ sessionId: created.id, mode: "review" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
}, [name, service]);
|
}, [name, service]);
|
||||||
|
|
||||||
|
const handleResetAll = useCallback(() => {
|
||||||
|
if (!pendingReset) {
|
||||||
|
setPendingReset(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearAllSessionData();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.hash = "";
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [pendingReset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="empty-state"
|
data-testid="empty-state"
|
||||||
@@ -99,6 +125,23 @@ export function CreateFirstSession() {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetAll}
|
||||||
|
data-testid="empty-state-reset-all"
|
||||||
|
title="Wipe every session, uploaded PDF, and annotation from this browser — then reload."
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#7a0000",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingReset ? "Confirm — wipe everything?" : "Reset all data…"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
import type { Session } from "@shared/session";
|
import type { Session } from "@shared/session";
|
||||||
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
|
import {
|
||||||
|
useActiveSession,
|
||||||
|
useDebugFlag,
|
||||||
|
useSessionListTick,
|
||||||
|
useSessionService,
|
||||||
|
} from "@work/index";
|
||||||
|
import { clearAllSessionData } from "@engine/index";
|
||||||
|
|
||||||
import { navigateTo } from "./routing";
|
import { navigateTo } from "./routing";
|
||||||
|
|
||||||
@@ -28,6 +35,11 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
const service = useSessionService();
|
const service = useSessionService();
|
||||||
const tick = useSessionListTick();
|
const tick = useSessionListTick();
|
||||||
const active = useActiveSession();
|
const active = useActiveSession();
|
||||||
|
const [debugTextLayer, setDebugTextLayer] = useDebugFlag("textLayer");
|
||||||
|
const [hideCanvas, setHideCanvas] = useDebugFlag("hideCanvas");
|
||||||
|
const [hideTextLayer, setHideTextLayer] = useDebugFlag("hideTextLayer");
|
||||||
|
const [hideAnnotationLayer, setHideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
|
||||||
|
const [hideXfaLayer, setHideXfaLayer] = useDebugFlag("hideXfaLayer");
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -35,6 +47,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [pendingDelete, setPendingDelete] = useState(false);
|
const [pendingDelete, setPendingDelete] = useState(false);
|
||||||
|
/** Per-row delete confirmation: id of the session armed for delete. */
|
||||||
|
const [pendingRowDelete, setPendingRowDelete] = useState<SessionId | null>(null);
|
||||||
|
const [pendingResetAll, setPendingResetAll] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -60,12 +75,28 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
setCreating(false);
|
setCreating(false);
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
setPendingDelete(false);
|
setPendingDelete(false);
|
||||||
|
setPendingRowDelete(null);
|
||||||
|
setPendingResetAll(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("mousedown", handler);
|
window.addEventListener("mousedown", handler);
|
||||||
return () => window.removeEventListener("mousedown", handler);
|
return () => window.removeEventListener("mousedown", handler);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Auto-clear pending row-delete after a few seconds so the user
|
||||||
|
// doesn't accidentally double-click much later and lose data.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingRowDelete) return;
|
||||||
|
const t = setTimeout(() => setPendingRowDelete(null), 3000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [pendingRowDelete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingResetAll) return;
|
||||||
|
const t = setTimeout(() => setPendingResetAll(false), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [pendingResetAll]);
|
||||||
|
|
||||||
const switchTo = useCallback(
|
const switchTo = useCallback(
|
||||||
(sessionId: import("@shared/ids").SessionId) => {
|
(sessionId: import("@shared/ids").SessionId) => {
|
||||||
navigateTo({ sessionId, mode: "review" });
|
navigateTo({ sessionId, mode: "review" });
|
||||||
@@ -77,7 +108,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = service.create(newName);
|
const trimmed = newName.trim();
|
||||||
|
const effective = trimmed.length === 0 ? service.nextDefaultName() : newName;
|
||||||
|
const created = service.create(effective);
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -111,6 +144,41 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
navigateTo({ sessionId: null, mode: "review" });
|
navigateTo({ sessionId: null, mode: "review" });
|
||||||
}, [active, pendingDelete, service]);
|
}, [active, pendingDelete, service]);
|
||||||
|
|
||||||
|
const handleRowDelete = useCallback(
|
||||||
|
(e: React.MouseEvent, id: SessionId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (pendingRowDelete !== id) {
|
||||||
|
setPendingRowDelete(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasActive = active?.id === id;
|
||||||
|
service.delete(id);
|
||||||
|
setPendingRowDelete(null);
|
||||||
|
if (wasActive) {
|
||||||
|
setOpen(false);
|
||||||
|
navigateTo({ sessionId: null, mode: "review" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[active, pendingRowDelete, service],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetAll = useCallback(() => {
|
||||||
|
if (!pendingResetAll) {
|
||||||
|
setPendingResetAll(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearAllSessionData();
|
||||||
|
setPendingResetAll(false);
|
||||||
|
setOpen(false);
|
||||||
|
// Force a full reload so every in-memory cache (sessions repo,
|
||||||
|
// byte stores, engine snapshots) starts fresh from the cleared
|
||||||
|
// localStorage.
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.hash = "";
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [pendingResetAll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
||||||
<button
|
<button
|
||||||
@@ -155,22 +223,48 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
||||||
Switch to…
|
Switch to…
|
||||||
</div>
|
</div>
|
||||||
{sessions.map((s) => (
|
{sessions.map((s) => {
|
||||||
<button
|
const rowArmed = pendingRowDelete === s.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid={`session-switch-${s.id}`}
|
data-testid={`session-switch-${s.id}`}
|
||||||
onClick={() => switchTo(s.id)}
|
onClick={() => switchTo(s.id)}
|
||||||
style={{
|
style={{ ...menuItemStyle, flex: 1 }}
|
||||||
...menuItemStyle,
|
|
||||||
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
{active?.id === s.id ? " · open" : ""}
|
{active?.id === s.id ? " · open" : ""}
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Delete session ${s.name}`}
|
||||||
|
data-testid={`session-row-delete-${s.id}`}
|
||||||
|
onClick={(e) => handleRowDelete(e, s.id)}
|
||||||
|
title={rowArmed ? "Click again to confirm" : "Delete session and drop all its data"}
|
||||||
|
style={{
|
||||||
|
background: rowArmed ? "#ffe5e5" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#7a0000",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px 8px",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowArmed ? "Confirm?" : "✕"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<hr style={dividerStyle} />
|
<hr style={dividerStyle} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -316,6 +410,66 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<hr style={dividerStyle} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 8px",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PDF diagnostics
|
||||||
|
</div>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Highlight text layer"
|
||||||
|
testid="session-menu-debug-textlayer"
|
||||||
|
title="Paint the PDF text-layer spans in light grey so you can see what's selectable. Logs every selection event to the browser console."
|
||||||
|
checked={debugTextLayer}
|
||||||
|
onChange={setDebugTextLayer}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide canvas layer"
|
||||||
|
testid="session-menu-hide-canvas"
|
||||||
|
title="Hide the rendered glyphs so only the text/annotation overlay layers remain. Use to see if the textLayer covers regions where the canvas has no content."
|
||||||
|
checked={hideCanvas}
|
||||||
|
onChange={setHideCanvas}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide text layer"
|
||||||
|
testid="session-menu-hide-textlayer"
|
||||||
|
title="Hide the invisible selection text layer entirely. Use to see if it's covering the canvas content underneath."
|
||||||
|
checked={hideTextLayer}
|
||||||
|
onChange={setHideTextLayer}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide annotation layer"
|
||||||
|
testid="session-menu-hide-annotation"
|
||||||
|
title="Hide PDF annotations (stamps, form widgets, links). Use to see if a stamp is obscuring content or capturing your clicks."
|
||||||
|
checked={hideAnnotationLayer}
|
||||||
|
onChange={setHideAnnotationLayer}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide XFA layer"
|
||||||
|
testid="session-menu-hide-xfa"
|
||||||
|
title="Hide the XFA form layer (rare; only present on Adobe XFA forms)."
|
||||||
|
checked={hideXfaLayer}
|
||||||
|
onChange={setHideXfaLayer}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="session-menu-reset-all"
|
||||||
|
onClick={handleResetAll}
|
||||||
|
title="Wipe every session, every uploaded PDF, every annotation — and reload."
|
||||||
|
style={{ ...menuItemStyle, color: "#7a0000" }}
|
||||||
|
>
|
||||||
|
{pendingResetAll
|
||||||
|
? "Confirm — wipe everything?"
|
||||||
|
: "Reset all data…"}
|
||||||
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
data-testid="session-menu-error"
|
data-testid="session-menu-error"
|
||||||
@@ -336,6 +490,38 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DebugCheckboxProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly testid: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly checked: boolean;
|
||||||
|
onChange(next: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DebugCheckbox(p: DebugCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-testid={p.testid}
|
||||||
|
title={p.title}
|
||||||
|
style={{
|
||||||
|
...menuItemStyle,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.checked}
|
||||||
|
onChange={(e) => p.onChange(e.target.checked)}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
{p.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const menuItemStyle: CSSProperties = {
|
const menuItemStyle: CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import type { EvidenceLink } from "@shared/evidence-link";
|
||||||
|
|
||||||
import type { EventBus } from "@engine/events";
|
import type { EventBus } from "@engine/events";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -68,9 +70,20 @@ export interface BinderProviderProps {
|
|||||||
* because its observers attach to the current `window`.
|
* because its observers attach to the current `window`.
|
||||||
*/
|
*/
|
||||||
readonly services?: Omit<BinderServices, "rect">;
|
readonly services?: Omit<BinderServices, "rect">;
|
||||||
|
/**
|
||||||
|
* Restored evidence links for this session. Seeded directly into the
|
||||||
|
* repo (no bus events) so reload does not spuriously re-emit
|
||||||
|
* `EvidenceLinkCreated`.
|
||||||
|
*/
|
||||||
|
readonly initialLinks?: readonly EvidenceLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BinderProvider({ children, bus, services }: BinderProviderProps) {
|
export function BinderProvider({
|
||||||
|
children,
|
||||||
|
bus,
|
||||||
|
services,
|
||||||
|
initialLinks,
|
||||||
|
}: BinderProviderProps) {
|
||||||
const built = useMemo<BinderServices>(() => {
|
const built = useMemo<BinderServices>(() => {
|
||||||
const links = services?.links ?? createInMemoryLinkRepo();
|
const links = services?.links ?? createInMemoryLinkRepo();
|
||||||
const bindings = services?.bindings ?? createBindingService(links, bus);
|
const bindings = services?.bindings ?? createBindingService(links, bus);
|
||||||
@@ -78,6 +91,15 @@ export function BinderProvider({ children, bus, services }: BinderProviderProps)
|
|||||||
return { links, bindings, rect };
|
return { links, bindings, rect };
|
||||||
}, [bus, services]);
|
}, [bus, services]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialLinks?.length || services?.links) return;
|
||||||
|
for (const link of initialLinks) {
|
||||||
|
if (!built.links.get(link.id)) {
|
||||||
|
built.links.create(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [built.links, initialLinks, services?.links]);
|
||||||
|
|
||||||
// Disconnect rect observers + listeners on unmount.
|
// Disconnect rect observers + listeners on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
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",
|
||||||
|
};
|
||||||
@@ -6,18 +6,14 @@
|
|||||||
* draw curves from the active field to its linked evidence card and on
|
* draw curves from the active field to its linked evidence card and on
|
||||||
* to the source highlight.
|
* to the source highlight.
|
||||||
*
|
*
|
||||||
* Lives in `src/binder/` (not `src/work/`) because it depends on the
|
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
|
||||||
* rect-registry hooks in `binder/visual-guide`. See `wiki/DependencyMap.md`
|
|
||||||
* §2/§5 for the `work ⊄ binder` rule that motivates this placement.
|
|
||||||
*
|
|
||||||
* No styling beyond minimum legibility (workplan T06 note). Tailwind /
|
|
||||||
* design system can land later without changing the registry contract.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, type ChangeEvent } from "react";
|
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
|
||||||
|
|
||||||
import type { EvidenceTarget } from "@shared/evidence-link";
|
import type { EvidenceTarget } from "@shared/evidence-link";
|
||||||
|
|
||||||
|
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
|
||||||
import { useActiveState, type ActiveState } from "./state/active";
|
import { useActiveState, type ActiveState } from "./state/active";
|
||||||
import { useRegisterRect } from "./visual-guide/react-hooks";
|
import { useRegisterRect } from "./visual-guide/react-hooks";
|
||||||
|
|
||||||
@@ -40,35 +36,91 @@ export interface FormSchema {
|
|||||||
readonly fields: readonly FormFieldSchema[];
|
readonly fields: readonly FormFieldSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldDefinitionPatch {
|
||||||
|
readonly label: string;
|
||||||
|
readonly type: FieldType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FormRendererProps {
|
export interface FormRendererProps {
|
||||||
readonly schema: FormSchema;
|
readonly schema: FormSchema;
|
||||||
readonly values?: Readonly<Record<string, string>>;
|
readonly values?: Readonly<Record<string, string>>;
|
||||||
readonly onValueChange?: (fieldId: string, value: string) => void;
|
readonly onValueChange?: (fieldId: string, value: string) => void;
|
||||||
/**
|
|
||||||
* Per-field annotation count. Rendered as a small chip beside the
|
|
||||||
* label so the user can tell which fields already have evidence.
|
|
||||||
*/
|
|
||||||
readonly linkCounts?: Readonly<Record<string, number>>;
|
readonly linkCounts?: Readonly<Record<string, number>>;
|
||||||
|
readonly linkHints?: Readonly<Record<string, string>>;
|
||||||
|
readonly showAddFieldForm?: boolean;
|
||||||
|
readonly onRequestAddField?: () => void;
|
||||||
|
readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void;
|
||||||
|
readonly onCancelAddField?: () => void;
|
||||||
|
readonly editingFieldId?: string | null;
|
||||||
|
readonly onBeginEditField?: (fieldId: string) => void;
|
||||||
|
readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void;
|
||||||
|
readonly onCancelFieldEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconButtonStyle: CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 6px",
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid #888",
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: "pointer",
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
|
|
||||||
function FieldRow({
|
function FieldRow({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
linkCount,
|
linkCount,
|
||||||
|
linkHint,
|
||||||
isActive,
|
isActive,
|
||||||
|
isEditing,
|
||||||
|
editLabel,
|
||||||
|
editType,
|
||||||
onChange,
|
onChange,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
onBeginEdit,
|
||||||
|
onChangeEditLabel,
|
||||||
|
onChangeEditType,
|
||||||
|
onSaveEdit,
|
||||||
|
onCancelEdit,
|
||||||
}: {
|
}: {
|
||||||
field: FormFieldSchema;
|
field: FormFieldSchema;
|
||||||
value: string;
|
value: string;
|
||||||
linkCount: number;
|
linkCount: number;
|
||||||
|
linkHint?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
editLabel: string;
|
||||||
|
editType: FieldType;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
|
onBeginEdit: () => void;
|
||||||
|
onChangeEditLabel: (next: string) => void;
|
||||||
|
onChangeEditType: (next: FieldType) => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useRegisterRect("field", field.id, ref);
|
useRegisterRect("field", field.id, ref);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-field-id={field.id} style={{ marginBottom: 12 }}>
|
||||||
|
<FieldDefinitionForm
|
||||||
|
label={editLabel}
|
||||||
|
type={editType}
|
||||||
|
onChangeLabel={onChangeEditLabel}
|
||||||
|
onChangeType={onChangeEditType}
|
||||||
|
onSave={onSaveEdit}
|
||||||
|
onCancel={onCancelEdit}
|
||||||
|
saveLabel="Save field"
|
||||||
|
badge="Editing field"
|
||||||
|
testidPrefix={`field-edit-${field.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sharedProps = {
|
const sharedProps = {
|
||||||
id: `field-${field.id}`,
|
id: `field-${field.id}`,
|
||||||
value,
|
value,
|
||||||
@@ -85,6 +137,7 @@ function FieldRow({
|
|||||||
data-link-count={String(linkCount)}
|
data-link-count={String(linkCount)}
|
||||||
aria-current={isActive ? "true" : undefined}
|
aria-current={isActive ? "true" : undefined}
|
||||||
style={{
|
style={{
|
||||||
|
position: "relative",
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
padding: 4,
|
padding: 4,
|
||||||
@@ -92,14 +145,40 @@ function FieldRow({
|
|||||||
background: isActive ? "#e8f0ff" : "transparent",
|
background: isActive ? "#e8f0ff" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit field ${field.label}`}
|
||||||
|
data-testid={`field-edit-toggle-${field.id}`}
|
||||||
|
title="Edit field label and type"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onBeginEdit();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...iconButtonStyle,
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
<label
|
<label
|
||||||
htmlFor={sharedProps.id}
|
htmlFor={sharedProps.id}
|
||||||
style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 4 }}
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingRight: 28,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{field.label}
|
{field.label}
|
||||||
{linkCount > 0 ? (
|
{linkCount > 0 ? (
|
||||||
<span
|
<span
|
||||||
data-testid={`field-${field.id}-chip`}
|
data-testid={`field-${field.id}-chip`}
|
||||||
|
title={linkHint}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
padding: "1px 6px",
|
padding: "1px 6px",
|
||||||
@@ -128,32 +207,116 @@ export function FormRenderer({
|
|||||||
values,
|
values,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
linkCounts,
|
linkCounts,
|
||||||
|
linkHints,
|
||||||
|
showAddFieldForm,
|
||||||
|
onRequestAddField,
|
||||||
|
onConfirmAddField,
|
||||||
|
onCancelAddField,
|
||||||
|
editingFieldId,
|
||||||
|
onBeginEditField,
|
||||||
|
onSaveFieldEdit,
|
||||||
|
onCancelFieldEdit,
|
||||||
}: FormRendererProps) {
|
}: FormRendererProps) {
|
||||||
const { state, focusTarget } = useActiveState();
|
const { state, focusTarget } = useActiveState();
|
||||||
|
const [addLabel, setAddLabel] = useState("New field");
|
||||||
|
const [addType, setAddType] = useState<FieldType>("text");
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
const [editType, setEditType] = useState<FieldType>("text");
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
||||||
focusTarget(target);
|
focusTarget(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const beginEdit = (field: FormFieldSchema) => {
|
||||||
|
setEditLabel(field.label);
|
||||||
|
setEditType(field.type);
|
||||||
|
onBeginEditField?.(field.id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
data-form-id={schema.id}
|
data-form-id={schema.id}
|
||||||
style={{ padding: 12 }}
|
style={{ padding: 12 }}
|
||||||
onSubmit={(e) => e.preventDefault()}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 14, marginTop: 0, fontFamily: "system-ui, sans-serif" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||||
{schema.title}
|
{schema.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="add-field-button"
|
||||||
|
onClick={() => {
|
||||||
|
setAddLabel(`New field ${schema.fields.length + 1}`);
|
||||||
|
setAddType("text");
|
||||||
|
onRequestAddField?.();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "4px 10px",
|
||||||
|
border: "1px solid #888",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddFieldForm && (
|
||||||
|
<FieldDefinitionForm
|
||||||
|
label={addLabel}
|
||||||
|
type={addType}
|
||||||
|
onChangeLabel={setAddLabel}
|
||||||
|
onChangeType={setAddType}
|
||||||
|
onSave={() =>
|
||||||
|
onConfirmAddField?.({
|
||||||
|
label: addLabel.trim(),
|
||||||
|
type: addType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => onCancelAddField?.()}
|
||||||
|
saveLabel="Add field"
|
||||||
|
badge="New form field"
|
||||||
|
testidPrefix="field-add"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{schema.fields.map((field) => (
|
{schema.fields.map((field) => (
|
||||||
<FieldRow
|
<FieldRow
|
||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
field={field}
|
||||||
value={values?.[field.id] ?? ""}
|
value={values?.[field.id] ?? ""}
|
||||||
linkCount={linkCounts?.[field.id] ?? 0}
|
linkCount={linkCounts?.[field.id] ?? 0}
|
||||||
|
{...(linkHints?.[field.id] != null
|
||||||
|
? { linkHint: linkHints[field.id] }
|
||||||
|
: {})}
|
||||||
isActive={isFieldActive(state, field.id)}
|
isActive={isFieldActive(state, field.id)}
|
||||||
|
isEditing={editingFieldId === field.id}
|
||||||
|
editLabel={editLabel}
|
||||||
|
editType={editType}
|
||||||
onChange={(next) => onValueChange?.(field.id, next)}
|
onChange={(next) => onValueChange?.(field.id, next)}
|
||||||
onFocus={() => handleFocus(field.id)}
|
onFocus={() => handleFocus(field.id)}
|
||||||
|
onBeginEdit={() => beginEdit(field)}
|
||||||
|
onChangeEditLabel={setEditLabel}
|
||||||
|
onChangeEditType={setEditType}
|
||||||
|
onSaveEdit={() =>
|
||||||
|
onSaveFieldEdit?.(field.id, {
|
||||||
|
label: editLabel.trim(),
|
||||||
|
type: editType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ export function createBindingService(
|
|||||||
return stored;
|
return stored;
|
||||||
},
|
},
|
||||||
unlinkEvidence(id) {
|
unlinkEvidence(id) {
|
||||||
return links.delete(id);
|
const removed = links.delete(id);
|
||||||
|
if (removed) {
|
||||||
|
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
},
|
},
|
||||||
updateLink(id, input) {
|
updateLink(id, input) {
|
||||||
const existing = links.get(id);
|
const existing = links.get(id);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type Action =
|
|||||||
evidenceItemId: EvidenceItemId;
|
evidenceItemId: EvidenceItemId;
|
||||||
annotationId: AnnotationId | null;
|
annotationId: AnnotationId | null;
|
||||||
}
|
}
|
||||||
|
| { type: "clear-active-evidence" }
|
||||||
| { type: "clear" };
|
| { type: "clear" };
|
||||||
|
|
||||||
function reducer(state: ActiveState, action: Action): ActiveState {
|
function reducer(state: ActiveState, action: Action): ActiveState {
|
||||||
@@ -78,6 +79,12 @@ function reducer(state: ActiveState, action: Action): ActiveState {
|
|||||||
activeEvidenceItemId: action.evidenceItemId,
|
activeEvidenceItemId: action.evidenceItemId,
|
||||||
activeAnnotationId: action.annotationId,
|
activeAnnotationId: action.annotationId,
|
||||||
};
|
};
|
||||||
|
case "clear-active-evidence":
|
||||||
|
return {
|
||||||
|
activeTarget: state.activeTarget,
|
||||||
|
activeEvidenceItemId: null,
|
||||||
|
activeAnnotationId: null,
|
||||||
|
};
|
||||||
case "clear":
|
case "clear":
|
||||||
return EMPTY_ACTIVE_STATE;
|
return EMPTY_ACTIVE_STATE;
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,7 @@ export interface ActiveStateApi {
|
|||||||
evidenceItemId: EvidenceItemId,
|
evidenceItemId: EvidenceItemId,
|
||||||
annotationId?: AnnotationId | null,
|
annotationId?: AnnotationId | null,
|
||||||
): void;
|
): void;
|
||||||
|
clearActiveEvidence(): void;
|
||||||
clear(): void;
|
clear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +152,17 @@ export function ActiveStateProvider(props: ActiveStateProviderProps) {
|
|||||||
[props.bus],
|
[props.bus],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearActiveEvidence = useCallback(() => {
|
||||||
|
dispatch({ type: "clear-active-evidence" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
dispatch({ type: "clear" });
|
dispatch({ type: "clear" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<ActiveStateApi>(
|
const value = useMemo<ActiveStateApi>(
|
||||||
() => ({ state, focusTarget, setActiveEvidence, clear }),
|
() => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
|
||||||
[state, focusTarget, setActiveEvidence, clear],
|
[state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
|
||||||
);
|
);
|
||||||
|
|
||||||
return createElement(ActiveStateContext.Provider, { value }, props.children);
|
return createElement(ActiveStateContext.Provider, { value }, props.children);
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
|
|||||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.bottom };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.top };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
||||||
* horizontally between them. The horizontal-bulge style is right for a
|
* horizontally between them. The horizontal-bulge style is right for a
|
||||||
@@ -55,8 +63,8 @@ export interface OverlayProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Overlay({
|
export function Overlay({
|
||||||
strokeColor = "#0050b3",
|
strokeColor = "#999",
|
||||||
strokeWidth = 2,
|
strokeWidth = 1,
|
||||||
className,
|
className,
|
||||||
}: OverlayProps = {}) {
|
}: OverlayProps = {}) {
|
||||||
const { state } = useActiveState();
|
const { state } = useActiveState();
|
||||||
@@ -72,10 +80,10 @@ export function Overlay({
|
|||||||
: null;
|
: null;
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
if (fieldRect && cardRect) {
|
if (fieldRect && cardRect) {
|
||||||
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
|
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
|
||||||
}
|
}
|
||||||
if (cardRect && highlightRect) {
|
if (cardRect && highlightRect) {
|
||||||
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
|
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
|
||||||
}
|
}
|
||||||
void version; // memo invalidator
|
void version; // memo invalidator
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -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,168 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
|
||||||
import type { Selector } from "@shared/selector";
|
|
||||||
import { createEngine, type Engine, type EngineEvent } from "./index";
|
|
||||||
|
|
||||||
function fakeDocAndRep(): { document: Document; representation: DocumentRepresentation } {
|
|
||||||
const docId = "doc_fake" as DocumentId;
|
|
||||||
const repId = "rep_fake" as RepresentationId;
|
|
||||||
return {
|
|
||||||
document: {
|
|
||||||
id: docId,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
representation: {
|
|
||||||
id: repId,
|
|
||||||
documentId: docId,
|
|
||||||
representationType: "pdf-text",
|
|
||||||
contentHash: "h",
|
|
||||||
canonicalText: "The quick brown fox.",
|
|
||||||
pageMap: [{ page: 1, width: 100, height: 100 }],
|
|
||||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
|
|
||||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Engine integration", () => {
|
|
||||||
let engine: Engine;
|
|
||||||
let events: EngineEvent[];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
engine = createEngine();
|
|
||||||
events = [];
|
|
||||||
engine.bus.onAny((e) => events.push(e));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("documentService.register stores both and emits DocumentImported + DocumentRepresentationGenerated", () => {
|
|
||||||
const { document, representation } = fakeDocAndRep();
|
|
||||||
const result = engine.documents.register({ document, representation });
|
|
||||||
expect(result.document).toBe(document);
|
|
||||||
expect(result.representation).toBe(representation);
|
|
||||||
expect(engine.documents.get(document.id)).toBe(document);
|
|
||||||
expect(engine.documents.getRepresentation(representation.id)).toBe(representation);
|
|
||||||
expect(events.map((e) => e.type)).toEqual(["DocumentImported", "DocumentRepresentationGenerated"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("annotationService.create stamps an ID + normalize version + timestamps, then emits AnnotationCreated", () => {
|
|
||||||
const { document, representation } = fakeDocAndRep();
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
const selectors: Selector[] = [{ type: "TextQuoteSelector", exact: "brown fox" }];
|
|
||||||
const ann = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors,
|
|
||||||
quote: "brown fox",
|
|
||||||
note: "a quick mark",
|
|
||||||
});
|
|
||||||
expect(ann.id).toMatch(/^ann_/);
|
|
||||||
expect(ann.normalizeVersion).toBeGreaterThan(0);
|
|
||||||
expect(ann.createdAt).toBe(ann.updatedAt);
|
|
||||||
expect(engine.annotations.get(ann.id)).toBe(ann);
|
|
||||||
const created = events.find((e) => e.type === "AnnotationCreated");
|
|
||||||
expect(created?.type).toBe("AnnotationCreated");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setResolutionStatus emits AnnotationResolved for resolved/ambiguous and AnnotationResolutionFailed for unresolved/stale", () => {
|
|
||||||
const { document, representation } = fakeDocAndRep();
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
const ann = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
|
|
||||||
});
|
|
||||||
events.length = 0;
|
|
||||||
engine.annotations.setResolutionStatus(ann.id, "resolved", { confidence: 0.95 });
|
|
||||||
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved"]);
|
|
||||||
engine.annotations.setResolutionStatus(ann.id, "unresolved", { confidence: 0, reason: "no quote match" });
|
|
||||||
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved", "AnnotationResolutionFailed"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("evidenceService.create requires at least one annotation and emits EvidenceItemCreated", () => {
|
|
||||||
const { document, representation } = fakeDocAndRep();
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
const ann = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
|
||||||
});
|
|
||||||
expect(() => engine.evidence.create({ annotationIds: [] })).toThrow();
|
|
||||||
const item = engine.evidence.create({
|
|
||||||
annotationIds: [ann.id],
|
|
||||||
commentary: "good quote",
|
|
||||||
});
|
|
||||||
expect(item.status).toBe("candidate");
|
|
||||||
expect(item.annotationIds).toEqual([ann.id]);
|
|
||||||
expect(events.find((e) => e.type === "EvidenceItemCreated")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setStatus emits EvidenceItemUpdated only on real change and carries previousStatus", () => {
|
|
||||||
const { document, representation } = fakeDocAndRep();
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
const ann = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
|
||||||
});
|
|
||||||
const item = engine.evidence.create({ annotationIds: [ann.id] });
|
|
||||||
events.length = 0;
|
|
||||||
const same = engine.evidence.setStatus(item.id, "candidate");
|
|
||||||
expect(same).toBe(item);
|
|
||||||
expect(events).toEqual([]);
|
|
||||||
engine.evidence.setStatus(item.id, "confirmed");
|
|
||||||
const updated = events.find((e) => e.type === "EvidenceItemUpdated");
|
|
||||||
expect(updated).toBeDefined();
|
|
||||||
if (updated?.type === "EvidenceItemUpdated") {
|
|
||||||
expect(updated.previousStatus).toBe("candidate");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listByDocument scopes evidence items to a single document via annotation lookup", () => {
|
|
||||||
const a = fakeDocAndRep();
|
|
||||||
engine.documents.register(a);
|
|
||||||
const annA = engine.annotations.create({
|
|
||||||
documentId: a.document.id,
|
|
||||||
representationId: a.representation.id,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
|
||||||
});
|
|
||||||
engine.evidence.create({ annotationIds: [annA.id], commentary: "a" });
|
|
||||||
|
|
||||||
// Second, distinct document.
|
|
||||||
const otherDocId = "doc_other" as DocumentId;
|
|
||||||
const otherRepId = "rep_other" as RepresentationId;
|
|
||||||
engine.documents.register({
|
|
||||||
document: { ...a.document, id: otherDocId },
|
|
||||||
representation: { ...a.representation, id: otherRepId, documentId: otherDocId },
|
|
||||||
});
|
|
||||||
const annB = engine.annotations.create({
|
|
||||||
documentId: otherDocId,
|
|
||||||
representationId: otherRepId,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "z" }],
|
|
||||||
});
|
|
||||||
engine.evidence.create({ annotationIds: [annB.id], commentary: "b" });
|
|
||||||
|
|
||||||
expect(engine.evidence.listByDocument(a.document.id)).toHaveLength(1);
|
|
||||||
expect(engine.evidence.listByDocument(otherDocId)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("activate emits EvidenceItemActivated without mutating the item", () => {
|
|
||||||
const { document, representation } = fakeDocAndRep();
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
const ann = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
|
|
||||||
});
|
|
||||||
const item = engine.evidence.create({ annotationIds: [ann.id] });
|
|
||||||
events.length = 0;
|
|
||||||
engine.evidence.activate(item.id, "sidebar");
|
|
||||||
const activated = events.find((e) => e.type === "EvidenceItemActivated");
|
|
||||||
expect(activated).toBeDefined();
|
|
||||||
if (activated?.type === "EvidenceItemActivated") {
|
|
||||||
expect(activated.source).toBe("sidebar");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { DocumentId } from "@shared/ids";
|
|
||||||
import { createEventBus } from "./bus";
|
|
||||||
|
|
||||||
const docId = "doc_test" as DocumentId;
|
|
||||||
const minimalDoc = {
|
|
||||||
id: docId,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("EventBus", () => {
|
|
||||||
it("delivers typed events to the registered listener", () => {
|
|
||||||
const bus = createEventBus();
|
|
||||||
const spy = vi.fn();
|
|
||||||
bus.on("DocumentImported", spy);
|
|
||||||
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
|
||||||
expect(spy).toHaveBeenCalledOnce();
|
|
||||||
expect(spy.mock.calls[0]![0]).toMatchObject({ type: "DocumentImported", documentId: docId });
|
|
||||||
expect(result.listenerCount).toBe(1);
|
|
||||||
expect(result.errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not deliver an event to listeners of a different type", () => {
|
|
||||||
const bus = createEventBus();
|
|
||||||
const spy = vi.fn();
|
|
||||||
bus.on("AnnotationCreated", spy);
|
|
||||||
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delivers every event to onAny listeners", () => {
|
|
||||||
const bus = createEventBus();
|
|
||||||
const spy = vi.fn();
|
|
||||||
bus.onAny(spy);
|
|
||||||
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
|
||||||
bus.emit({ type: "EvidenceItemActivated", evidenceItemId: "ev_x" as never });
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an unsubscribe function from on()", () => {
|
|
||||||
const bus = createEventBus();
|
|
||||||
const spy = vi.fn();
|
|
||||||
const off = bus.on("DocumentImported", spy);
|
|
||||||
off();
|
|
||||||
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures listener errors and still calls subsequent listeners", () => {
|
|
||||||
const bus = createEventBus();
|
|
||||||
const boom = new Error("listener exploded");
|
|
||||||
const a = vi.fn(() => { throw boom; });
|
|
||||||
const b = vi.fn();
|
|
||||||
bus.on("DocumentImported", a);
|
|
||||||
bus.on("DocumentImported", b);
|
|
||||||
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
|
||||||
expect(a).toHaveBeenCalledOnce();
|
|
||||||
expect(b).toHaveBeenCalledOnce();
|
|
||||||
expect(result.errors).toEqual([boom]);
|
|
||||||
expect(result.listenerCount).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Synchronous in-process event bus.
|
|
||||||
*
|
|
||||||
* Listeners fire in registration order on the calling stack; `emit` returns
|
|
||||||
* after every listener has run. A listener throwing does not stop later
|
|
||||||
* listeners — its error surfaces through the returned `errors` array so
|
|
||||||
* callers can decide whether to log, rethrow, or ignore.
|
|
||||||
*
|
|
||||||
* MVP-sufficient. ADR-0005 (persistence) will decide whether to upgrade to
|
|
||||||
* an async/queued bus when storage becomes durable.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EngineEvent, EngineEventOf, EngineEventType } from "./types";
|
|
||||||
|
|
||||||
export type EngineEventListener<T extends EngineEventType = EngineEventType> = (
|
|
||||||
event: EngineEventOf<T>,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export type AnyEngineEventListener = (event: EngineEvent) => void;
|
|
||||||
|
|
||||||
export interface EmitResult {
|
|
||||||
readonly listenerCount: number;
|
|
||||||
readonly errors: readonly unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventBus {
|
|
||||||
on<T extends EngineEventType>(type: T, listener: EngineEventListener<T>): () => void;
|
|
||||||
onAny(listener: AnyEngineEventListener): () => void;
|
|
||||||
emit<T extends EngineEventType>(event: EngineEventOf<T>): EmitResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEventBus(): EventBus {
|
|
||||||
const typedListeners = new Map<EngineEventType, Set<EngineEventListener>>();
|
|
||||||
const anyListeners = new Set<AnyEngineEventListener>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
on(type, listener) {
|
|
||||||
let set = typedListeners.get(type);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
typedListeners.set(type, set);
|
|
||||||
}
|
|
||||||
set.add(listener as unknown as EngineEventListener);
|
|
||||||
return () => {
|
|
||||||
set!.delete(listener as unknown as EngineEventListener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onAny(listener) {
|
|
||||||
anyListeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
anyListeners.delete(listener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
emit(event) {
|
|
||||||
const errors: unknown[] = [];
|
|
||||||
let count = 0;
|
|
||||||
const typedSet = typedListeners.get(event.type);
|
|
||||||
if (typedSet) {
|
|
||||||
for (const l of typedSet) {
|
|
||||||
count++;
|
|
||||||
try {
|
|
||||||
(l as AnyEngineEventListener)(event);
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const l of anyListeners) {
|
|
||||||
count++;
|
|
||||||
try {
|
|
||||||
l(event);
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { listenerCount: count, errors };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export * from "./types";
|
|
||||||
export {
|
|
||||||
createEventBus,
|
|
||||||
type EventBus,
|
|
||||||
type EngineEventListener,
|
|
||||||
type AnyEngineEventListener,
|
|
||||||
type EmitResult,
|
|
||||||
} from "./bus";
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* Engine event vocabulary.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §4 (closed event list). Each event
|
|
||||||
* carries the *minimum* identifying payload needed by downstream listeners;
|
|
||||||
* services hand back the full domain object to the caller separately.
|
|
||||||
*
|
|
||||||
* Adding an event requires updating SharedContracts.md first.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
|
|
||||||
import type {
|
|
||||||
EvidenceLink,
|
|
||||||
EvidenceTarget,
|
|
||||||
} from "@shared/evidence-link";
|
|
||||||
import type {
|
|
||||||
AnnotationId,
|
|
||||||
DocumentId,
|
|
||||||
EvidenceItemId,
|
|
||||||
EvidenceLinkId,
|
|
||||||
RepresentationId,
|
|
||||||
SessionId,
|
|
||||||
} from "@shared/ids";
|
|
||||||
import type { Session } from "@shared/session";
|
|
||||||
|
|
||||||
export interface DocumentImportedEvent {
|
|
||||||
readonly type: "DocumentImported";
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly document: Document;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocumentRepresentationGeneratedEvent {
|
|
||||||
readonly type: "DocumentRepresentationGenerated";
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly representationId: RepresentationId;
|
|
||||||
readonly representation: DocumentRepresentation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocumentRemovedEvent {
|
|
||||||
readonly type: "DocumentRemoved";
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnotationCreatedEvent {
|
|
||||||
readonly type: "AnnotationCreated";
|
|
||||||
readonly annotationId: AnnotationId;
|
|
||||||
readonly annotation: Annotation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnotationResolvedEvent {
|
|
||||||
readonly type: "AnnotationResolved";
|
|
||||||
readonly annotationId: AnnotationId;
|
|
||||||
readonly status: AnnotationResolutionStatus;
|
|
||||||
readonly confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnotationResolutionFailedEvent {
|
|
||||||
readonly type: "AnnotationResolutionFailed";
|
|
||||||
readonly annotationId: AnnotationId;
|
|
||||||
readonly reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceItemCreatedEvent {
|
|
||||||
readonly type: "EvidenceItemCreated";
|
|
||||||
readonly evidenceItemId: EvidenceItemId;
|
|
||||||
readonly evidenceItem: EvidenceItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceItemUpdatedEvent {
|
|
||||||
readonly type: "EvidenceItemUpdated";
|
|
||||||
readonly evidenceItemId: EvidenceItemId;
|
|
||||||
readonly evidenceItem: EvidenceItem;
|
|
||||||
readonly previousStatus: EvidenceItemStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceItemActivatedEvent {
|
|
||||||
readonly type: "EvidenceItemActivated";
|
|
||||||
readonly evidenceItemId: EvidenceItemId;
|
|
||||||
readonly source?: "sidebar" | "form-field" | "citation-card";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceLinkCreatedEvent {
|
|
||||||
readonly type: "EvidenceLinkCreated";
|
|
||||||
readonly linkId: EvidenceLinkId;
|
|
||||||
readonly link: EvidenceLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceLinkUpdatedEvent {
|
|
||||||
readonly type: "EvidenceLinkUpdated";
|
|
||||||
readonly linkId: EvidenceLinkId;
|
|
||||||
readonly link: EvidenceLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormFieldActivatedEvent {
|
|
||||||
readonly type: "FormFieldActivated";
|
|
||||||
readonly target: EvidenceTarget;
|
|
||||||
readonly previousTarget?: EvidenceTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionCreatedEvent {
|
|
||||||
readonly type: "SessionCreated";
|
|
||||||
readonly sessionId: SessionId;
|
|
||||||
readonly session: Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionRenamedEvent {
|
|
||||||
readonly type: "SessionRenamed";
|
|
||||||
readonly sessionId: SessionId;
|
|
||||||
readonly session: Session;
|
|
||||||
readonly previousName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionDeletedEvent {
|
|
||||||
readonly type: "SessionDeleted";
|
|
||||||
readonly sessionId: SessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionActivatedEvent {
|
|
||||||
readonly type: "SessionActivated";
|
|
||||||
readonly sessionId: SessionId | null;
|
|
||||||
readonly previousSessionId: SessionId | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EngineEvent =
|
|
||||||
| DocumentImportedEvent
|
|
||||||
| DocumentRepresentationGeneratedEvent
|
|
||||||
| DocumentRemovedEvent
|
|
||||||
| AnnotationCreatedEvent
|
|
||||||
| AnnotationResolvedEvent
|
|
||||||
| AnnotationResolutionFailedEvent
|
|
||||||
| EvidenceItemCreatedEvent
|
|
||||||
| EvidenceItemUpdatedEvent
|
|
||||||
| EvidenceItemActivatedEvent
|
|
||||||
| EvidenceLinkCreatedEvent
|
|
||||||
| EvidenceLinkUpdatedEvent
|
|
||||||
| FormFieldActivatedEvent
|
|
||||||
| SessionCreatedEvent
|
|
||||||
| SessionRenamedEvent
|
|
||||||
| SessionDeletedEvent
|
|
||||||
| SessionActivatedEvent;
|
|
||||||
|
|
||||||
export type EngineEventType = EngineEvent["type"];
|
|
||||||
|
|
||||||
export type EngineEventOf<T extends EngineEventType> = Extract<EngineEvent, { type: T }>;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Engine composition root.
|
|
||||||
*
|
|
||||||
* `createEngine()` wires in-memory repos to the services and shares a single
|
|
||||||
* event bus. The app layer holds the returned `Engine` instance and passes
|
|
||||||
* its services into the UI.
|
|
||||||
*
|
|
||||||
* Swapping the repository implementation later (ADR-0005) is a matter of
|
|
||||||
* replacing `createInMemoryRepos()` here. The service signatures don't
|
|
||||||
* change.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createEventBus, type EventBus } from "./events";
|
|
||||||
import {
|
|
||||||
createInMemoryRepos,
|
|
||||||
type InMemoryRepos,
|
|
||||||
} from "./repos";
|
|
||||||
import {
|
|
||||||
createAnnotationService,
|
|
||||||
createDocumentService,
|
|
||||||
createEvidenceService,
|
|
||||||
type AnnotationService,
|
|
||||||
type DocumentService,
|
|
||||||
type EvidenceService,
|
|
||||||
} from "./services";
|
|
||||||
|
|
||||||
export * from "./events";
|
|
||||||
export * from "./repos";
|
|
||||||
export * from "./services";
|
|
||||||
export * from "./rendering";
|
|
||||||
export {
|
|
||||||
SNAPSHOT_VERSION,
|
|
||||||
attachPersister,
|
|
||||||
captureSnapshot,
|
|
||||||
documentIdsIn,
|
|
||||||
restoreFromStorage,
|
|
||||||
restoreSnapshot,
|
|
||||||
type EngineSnapshot,
|
|
||||||
type PersisterOptions,
|
|
||||||
} from "./persistence";
|
|
||||||
|
|
||||||
export interface Engine {
|
|
||||||
readonly bus: EventBus;
|
|
||||||
readonly repos: InMemoryRepos;
|
|
||||||
readonly documents: DocumentService;
|
|
||||||
readonly annotations: AnnotationService;
|
|
||||||
readonly evidence: EvidenceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEngine(): Engine {
|
|
||||||
const bus = createEventBus();
|
|
||||||
const repos = createInMemoryRepos();
|
|
||||||
const documents = createDocumentService(repos.documents, repos.representations, bus);
|
|
||||||
const annotations = createAnnotationService(repos.annotations, bus);
|
|
||||||
const evidence = createEvidenceService(
|
|
||||||
repos.evidenceItems,
|
|
||||||
(id) => repos.annotations.get(id),
|
|
||||||
bus,
|
|
||||||
);
|
|
||||||
return { bus, repos, documents, annotations, evidence };
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
|
||||||
import {
|
|
||||||
attachPersister,
|
|
||||||
captureSnapshot,
|
|
||||||
createEngine,
|
|
||||||
restoreFromStorage,
|
|
||||||
restoreSnapshot,
|
|
||||||
type Engine,
|
|
||||||
type EngineEvent,
|
|
||||||
type EngineSnapshot,
|
|
||||||
} from "./index";
|
|
||||||
|
|
||||||
function fakeDocAndRep(suffix: string): {
|
|
||||||
document: Document;
|
|
||||||
representation: DocumentRepresentation;
|
|
||||||
} {
|
|
||||||
const docId = `doc_${suffix}` as DocumentId;
|
|
||||||
const repId = `rep_${suffix}` as RepresentationId;
|
|
||||||
return {
|
|
||||||
document: {
|
|
||||||
id: docId,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
title: `Doc ${suffix}`,
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
representation: {
|
|
||||||
id: repId,
|
|
||||||
documentId: docId,
|
|
||||||
representationType: "pdf-text",
|
|
||||||
contentHash: `hash-${suffix}`,
|
|
||||||
canonicalText: "The quick brown fox.",
|
|
||||||
pageMap: [{ page: 1, width: 100, height: 100 }],
|
|
||||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
|
|
||||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
getItem: (k) => map.get(k) ?? null,
|
|
||||||
setItem: (k, v) => void map.set(k, v),
|
|
||||||
removeItem: (k) => void map.delete(k),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function seed(engine: Engine, suffix: string) {
|
|
||||||
const { document, representation } = fakeDocAndRep(suffix);
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
const ann = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
|
||||||
quote: "brown fox",
|
|
||||||
});
|
|
||||||
const item = engine.evidence.create({
|
|
||||||
annotationIds: [ann.id],
|
|
||||||
commentary: `commentary-${suffix}`,
|
|
||||||
});
|
|
||||||
return { document, representation, ann, item };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("captureSnapshot + restoreSnapshot", () => {
|
|
||||||
it("round-trips documents, representations, annotations and evidence items", () => {
|
|
||||||
const src = createEngine();
|
|
||||||
const a = seed(src, "a");
|
|
||||||
const b = seed(src, "b");
|
|
||||||
const snap = captureSnapshot(src);
|
|
||||||
expect(snap.documents).toHaveLength(2);
|
|
||||||
expect(snap.representations).toHaveLength(2);
|
|
||||||
expect(snap.annotations).toHaveLength(2);
|
|
||||||
expect(snap.evidenceItems).toHaveLength(2);
|
|
||||||
|
|
||||||
const dst = createEngine();
|
|
||||||
restoreSnapshot(dst, snap);
|
|
||||||
expect(dst.documents.get(a.document.id)?.title).toBe("Doc a");
|
|
||||||
expect(dst.documents.get(b.document.id)?.title).toBe("Doc b");
|
|
||||||
expect(dst.annotations.get(a.ann.id)?.quote).toBe("brown fox");
|
|
||||||
expect(dst.evidence.get(a.item.id)?.commentary).toBe("commentary-a");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restoreSnapshot does NOT emit *Created events (events would loop the persister)", () => {
|
|
||||||
const src = createEngine();
|
|
||||||
seed(src, "x");
|
|
||||||
const snap = captureSnapshot(src);
|
|
||||||
|
|
||||||
const dst = createEngine();
|
|
||||||
const seen: EngineEvent["type"][] = [];
|
|
||||||
dst.bus.onAny((e) => seen.push(e.type));
|
|
||||||
restoreSnapshot(dst, snap);
|
|
||||||
expect(seen).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a snapshot with a mismatching version", () => {
|
|
||||||
const dst = createEngine();
|
|
||||||
expect(() =>
|
|
||||||
restoreSnapshot(dst, {
|
|
||||||
version: 999,
|
|
||||||
documents: [],
|
|
||||||
representations: [],
|
|
||||||
annotations: [],
|
|
||||||
evidenceItems: [],
|
|
||||||
} as EngineSnapshot),
|
|
||||||
).toThrow(/version/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("attachPersister", () => {
|
|
||||||
let storage: ReturnType<typeof memoryStorage>;
|
|
||||||
let engine: Engine;
|
|
||||||
const KEY = "ce-test-snap";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
storage = memoryStorage();
|
|
||||||
engine = createEngine();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes a snapshot to storage on every mutating event", () => {
|
|
||||||
const off = attachPersister(engine, { key: KEY, storage });
|
|
||||||
expect(storage.getItem(KEY)).toBeNull();
|
|
||||||
seed(engine, "z");
|
|
||||||
const raw = storage.getItem(KEY);
|
|
||||||
expect(raw).not.toBeNull();
|
|
||||||
const snap = JSON.parse(raw!) as EngineSnapshot;
|
|
||||||
expect(snap.documents).toHaveLength(1);
|
|
||||||
expect(snap.evidenceItems).toHaveLength(1);
|
|
||||||
off();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops writing after the unsubscribe is called", () => {
|
|
||||||
const off = attachPersister(engine, { key: KEY, storage });
|
|
||||||
seed(engine, "q");
|
|
||||||
const after = storage.getItem(KEY);
|
|
||||||
off();
|
|
||||||
seed(engine, "r");
|
|
||||||
expect(storage.getItem(KEY)).toBe(after);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("survives a JSON.stringify failure without throwing into the caller", () => {
|
|
||||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const failing = { ...memoryStorage(), setItem: () => { throw new Error("boom"); } };
|
|
||||||
attachPersister(engine, { key: KEY, storage: failing });
|
|
||||||
expect(() => seed(engine, "k")).not.toThrow();
|
|
||||||
expect(warn).toHaveBeenCalled();
|
|
||||||
warn.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("restoreFromStorage", () => {
|
|
||||||
it("returns {restored: false} when the key is empty", () => {
|
|
||||||
const storage = memoryStorage();
|
|
||||||
const engine = createEngine();
|
|
||||||
const result = restoreFromStorage(engine, { key: "missing", storage });
|
|
||||||
expect(result.restored).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hydrates the engine when storage holds a valid snapshot", () => {
|
|
||||||
const src = createEngine();
|
|
||||||
seed(src, "rs");
|
|
||||||
const storage = memoryStorage();
|
|
||||||
storage.setItem("snap", JSON.stringify(captureSnapshot(src)));
|
|
||||||
|
|
||||||
const dst = createEngine();
|
|
||||||
const result = restoreFromStorage(dst, { key: "snap", storage });
|
|
||||||
expect(result.restored).toBe(true);
|
|
||||||
expect(dst.documents.list()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores malformed JSON without throwing", () => {
|
|
||||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const storage = memoryStorage();
|
|
||||||
storage.setItem("snap", "not-json");
|
|
||||||
const engine = createEngine();
|
|
||||||
const result = restoreFromStorage(engine, { key: "snap", storage });
|
|
||||||
expect(result.restored).toBe(false);
|
|
||||||
expect(warn).toHaveBeenCalled();
|
|
||||||
warn.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* Engine snapshot + restore.
|
|
||||||
*
|
|
||||||
* MVP "persistence" — capture the engine's in-memory state into a JSON blob
|
|
||||||
* and restore it later. Used by the SPA to survive page reloads via
|
|
||||||
* `localStorage` until ADR-0005 lands a real store.
|
|
||||||
*
|
|
||||||
* Restore deliberately bypasses the service layer: it writes directly to
|
|
||||||
* the repos so no `*Created` events fire. Without that, restoring would
|
|
||||||
* trigger the persister to re-write the same snapshot — and if the user
|
|
||||||
* has another tab open, it would also broadcast spurious "this annotation
|
|
||||||
* just appeared" events to UI listeners.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
|
||||||
import type { DocumentId } from "@shared/ids";
|
|
||||||
|
|
||||||
import type { Engine } from "./index";
|
|
||||||
|
|
||||||
export const SNAPSHOT_VERSION = 1;
|
|
||||||
|
|
||||||
export interface EngineSnapshot {
|
|
||||||
readonly version: number;
|
|
||||||
readonly documents: readonly Document[];
|
|
||||||
readonly representations: readonly DocumentRepresentation[];
|
|
||||||
readonly annotations: readonly Annotation[];
|
|
||||||
readonly evidenceItems: readonly EvidenceItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureSnapshot(engine: Engine): EngineSnapshot {
|
|
||||||
const documents = engine.documents.list();
|
|
||||||
// Gather representations per known document.
|
|
||||||
const representations: DocumentRepresentation[] = [];
|
|
||||||
const annotations: Annotation[] = [];
|
|
||||||
const evidenceItems: EvidenceItem[] = [];
|
|
||||||
const seenItemIds = new Set<string>();
|
|
||||||
for (const doc of documents) {
|
|
||||||
representations.push(...engine.documents.listRepresentations(doc.id));
|
|
||||||
annotations.push(...engine.annotations.listByDocument(doc.id));
|
|
||||||
for (const item of engine.evidence.listByDocument(doc.id)) {
|
|
||||||
// listByDocument keys off annotation lookup; an item that shares
|
|
||||||
// annotations across two documents would surface twice. De-dupe.
|
|
||||||
if (!seenItemIds.has(item.id)) {
|
|
||||||
seenItemIds.add(item.id);
|
|
||||||
evidenceItems.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
version: SNAPSHOT_VERSION,
|
|
||||||
documents: [...documents],
|
|
||||||
representations,
|
|
||||||
annotations,
|
|
||||||
evidenceItems,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreSnapshot(engine: Engine, snapshot: EngineSnapshot): void {
|
|
||||||
if (snapshot.version !== SNAPSHOT_VERSION) {
|
|
||||||
throw new Error(
|
|
||||||
`restoreSnapshot: snapshot version ${snapshot.version} does not match current ${SNAPSHOT_VERSION}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const d of snapshot.documents) engine.repos.documents.create(d);
|
|
||||||
for (const r of snapshot.representations) engine.repos.representations.create(r);
|
|
||||||
for (const a of snapshot.annotations) engine.repos.annotations.create(a);
|
|
||||||
for (const i of snapshot.evidenceItems) engine.repos.evidenceItems.create(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersisterOptions {
|
|
||||||
/** Storage key. */
|
|
||||||
readonly key: string;
|
|
||||||
/** Storage shim — defaults to globalThis.localStorage. */
|
|
||||||
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to engine events and write a fresh snapshot on every mutation.
|
|
||||||
* Returns the unsubscribe function.
|
|
||||||
*
|
|
||||||
* Initial snapshot is NOT written — call `captureSnapshot` + `storage.setItem`
|
|
||||||
* yourself if you want a baseline.
|
|
||||||
*/
|
|
||||||
export function attachPersister(engine: Engine, options: PersisterOptions): () => void {
|
|
||||||
const storage = options.storage ?? globalThis.localStorage;
|
|
||||||
const write = () => {
|
|
||||||
const snap = captureSnapshot(engine);
|
|
||||||
try {
|
|
||||||
storage.setItem(options.key, JSON.stringify(snap));
|
|
||||||
} catch (err) {
|
|
||||||
// localStorage quota / serialization errors shouldn't crash the app.
|
|
||||||
// Surface to the console; ADR-0005 owns the durable fix.
|
|
||||||
console.warn("attachPersister: write failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const offs = [
|
|
||||||
engine.bus.on("DocumentImported", write),
|
|
||||||
engine.bus.on("DocumentRepresentationGenerated", write),
|
|
||||||
engine.bus.on("AnnotationCreated", write),
|
|
||||||
engine.bus.on("AnnotationResolved", write),
|
|
||||||
engine.bus.on("AnnotationResolutionFailed", write),
|
|
||||||
engine.bus.on("EvidenceItemCreated", write),
|
|
||||||
engine.bus.on("EvidenceItemUpdated", write),
|
|
||||||
];
|
|
||||||
return () => {
|
|
||||||
for (const off of offs) off();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RestoreFromStorageOptions = PersisterOptions;
|
|
||||||
|
|
||||||
export function restoreFromStorage(
|
|
||||||
engine: Engine,
|
|
||||||
options: RestoreFromStorageOptions,
|
|
||||||
): { readonly restored: boolean; readonly snapshot?: EngineSnapshot } {
|
|
||||||
const storage = options.storage ?? globalThis.localStorage;
|
|
||||||
const raw = storage.getItem(options.key);
|
|
||||||
if (!raw) return { restored: false };
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as EngineSnapshot;
|
|
||||||
if (typeof parsed !== "object" || parsed === null) return { restored: false };
|
|
||||||
restoreSnapshot(engine, parsed);
|
|
||||||
return { restored: true, snapshot: parsed };
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("restoreFromStorage: parse failed, ignoring stored snapshot", err);
|
|
||||||
return { restored: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrow helper: get the set of document ids restored from a snapshot.
|
|
||||||
* Useful for the SPA's "show me what was open last time" logic.
|
|
||||||
*/
|
|
||||||
export function documentIdsIn(snapshot: EngineSnapshot): readonly DocumentId[] {
|
|
||||||
return snapshot.documents.map((d) => d.id);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* HTML citation card renderer tests (CE-WP-0004-T03).
|
|
||||||
*
|
|
||||||
* Snapshots lock the output format defined in
|
|
||||||
* `docs/decisions/ADR-0007-citation-card-format.md`. Class names are
|
|
||||||
* part of the public contract — renaming any of them requires updating
|
|
||||||
* both this test and the ADR.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import type { Document } from "@shared/document";
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
|
||||||
import type {
|
|
||||||
AnnotationId,
|
|
||||||
DocumentId,
|
|
||||||
EvidenceItemId,
|
|
||||||
RepresentationId,
|
|
||||||
} from "@shared/ids";
|
|
||||||
|
|
||||||
import { renderCitationCardHtml } from "./html";
|
|
||||||
|
|
||||||
const DOC_ID = "doc_2024-order" as DocumentId;
|
|
||||||
const REP_ID = "rep_2024-order" as RepresentationId;
|
|
||||||
const ANN_ID = "ann_para3" as AnnotationId;
|
|
||||||
const EV_ID = "ev_para3" as EvidenceItemId;
|
|
||||||
|
|
||||||
function makeDoc(overrides: Partial<Document> = {}): Document {
|
|
||||||
return {
|
|
||||||
id: DOC_ID,
|
|
||||||
title: "Order from 14 Mar 2024",
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
|
|
||||||
return {
|
|
||||||
id: ANN_ID,
|
|
||||||
documentId: DOC_ID,
|
|
||||||
representationId: REP_ID,
|
|
||||||
selectors: [],
|
|
||||||
quote: "Die Frist endet am 31. März 2024.",
|
|
||||||
normalizeVersion: 1,
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
|
|
||||||
return {
|
|
||||||
id: EV_ID,
|
|
||||||
annotationIds: [ANN_ID],
|
|
||||||
status: "candidate",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("renderCitationCardHtml()", () => {
|
|
||||||
it("renders the full aside with quote, attribution, and commentary", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"<aside class="citation-card">
|
|
||||||
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
|
||||||
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&annotation=ann_para3">Open source</a></p>
|
|
||||||
<div class="citation-card__commentary">Deadline clause for the buyer.</div>
|
|
||||||
</aside>
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the commentary div when none is set", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"<aside class="citation-card">
|
|
||||||
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
|
||||||
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&annotation=ann_para3">Open source</a></p>
|
|
||||||
</aside>
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts \\n inside the quote to <br> for rich-text paste", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn({ quote: "Line one.\nLine two." }),
|
|
||||||
});
|
|
||||||
expect(out).toContain(
|
|
||||||
'<blockquote class="citation-card__quote">Line one.<br>Line two.</blockquote>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv({
|
|
||||||
commentary: `Notes: <script>alert("xss")</script> & 'untrusted'`,
|
|
||||||
}),
|
|
||||||
document: makeDoc({ title: `Order "draft" & more` }),
|
|
||||||
annotation: makeAnn({ quote: "<<value>> & 'inner'" }),
|
|
||||||
});
|
|
||||||
// Quote escaping
|
|
||||||
expect(out).toContain(
|
|
||||||
"<blockquote class=\"citation-card__quote\"><<value>> & 'inner'</blockquote>",
|
|
||||||
);
|
|
||||||
// Source label escaping
|
|
||||||
expect(out).toContain(
|
|
||||||
"<cite class=\"citation-card__source\">Order "draft" & more</cite>",
|
|
||||||
);
|
|
||||||
// Commentary escaping — and especially: no raw <script>
|
|
||||||
expect(out).not.toContain("<script>");
|
|
||||||
expect(out).toContain(
|
|
||||||
"<div class=\"citation-card__commentary\">Notes: <script>alert("xss")</script> & 'untrusted'</div>",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to metadata.filename when document.title is missing", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: {
|
|
||||||
id: DOC_ID,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
metadata: { filename: "order-2024.pdf" },
|
|
||||||
},
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toContain(
|
|
||||||
'<cite class="citation-card__source">order-2024.pdf</cite>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the · separator and link when openContextUrlOverride is empty", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
openContextUrlOverride: "",
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"<aside class="citation-card">
|
|
||||||
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
|
||||||
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite></p>
|
|
||||||
</aside>
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not emit inline style attributes (host page controls CSS)", () => {
|
|
||||||
const out = renderCitationCardHtml({
|
|
||||||
evidenceItem: makeEv({ commentary: "Some commentary." }),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).not.toMatch(/style=/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* HTML citation card renderer.
|
|
||||||
*
|
|
||||||
* Implements CE-WP-0004-T03. Output format and rules are locked in
|
|
||||||
* `docs/decisions/ADR-0007-citation-card-format.md`.
|
|
||||||
*
|
|
||||||
* The output is a single `<aside class="citation-card">` element with no
|
|
||||||
* inline styles — host pages provide the CSS. Commentary is treated as
|
|
||||||
* plain text; no Markdown or raw HTML passthrough.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import { resolveSourceLabel } from "@shared/citation-card-source";
|
|
||||||
import type { Document } from "@shared/document";
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
|
||||||
import { openContextUrl } from "@shared/open-context-url";
|
|
||||||
|
|
||||||
export interface RenderCitationCardHtmlInput {
|
|
||||||
readonly evidenceItem: EvidenceItem;
|
|
||||||
readonly document: Document;
|
|
||||||
readonly annotation: Annotation;
|
|
||||||
/**
|
|
||||||
* Override the deep-link URL — same semantics as
|
|
||||||
* `renderCitationCardMarkdown` (host pages with a non-default mount
|
|
||||||
* prefix). Defaults to `openContextUrl({…})`.
|
|
||||||
*/
|
|
||||||
readonly openContextUrlOverride?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HTML_ENTITIES: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
|
||||||
return value.replace(/[&<>"']/g, (c) => HTML_ENTITIES[c]!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert source line breaks to `<br>` inside the blockquote so a
|
|
||||||
* multi-line quote stays multi-line in rich-text paste targets. Each
|
|
||||||
* line is HTML-escaped first.
|
|
||||||
*/
|
|
||||||
function escapeQuoteWithLineBreaks(quote: string): string {
|
|
||||||
return quote.split("\n").map(escapeHtml).join("<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderCitationCardHtml({
|
|
||||||
evidenceItem,
|
|
||||||
document,
|
|
||||||
annotation,
|
|
||||||
openContextUrlOverride,
|
|
||||||
}: RenderCitationCardHtmlInput): string {
|
|
||||||
const quote = annotation.quote ?? "";
|
|
||||||
const sourceLabel = resolveSourceLabel(document);
|
|
||||||
|
|
||||||
const url =
|
|
||||||
openContextUrlOverride !== undefined
|
|
||||||
? openContextUrlOverride
|
|
||||||
: openContextUrl({
|
|
||||||
documentId: document.id,
|
|
||||||
annotationId: annotation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const quoteHtml = escapeQuoteWithLineBreaks(quote);
|
|
||||||
const sourceHtml = escapeHtml(sourceLabel);
|
|
||||||
|
|
||||||
const linkSegment =
|
|
||||||
url.length > 0
|
|
||||||
? ` · <a class="citation-card__link" href="${escapeHtml(url)}">Open source</a>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const attributionHtml = `<cite class="citation-card__source">${sourceHtml}</cite>${linkSegment}`;
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
'<aside class="citation-card">',
|
|
||||||
` <blockquote class="citation-card__quote">${quoteHtml}</blockquote>`,
|
|
||||||
` <p class="citation-card__attribution">${attributionHtml}</p>`,
|
|
||||||
];
|
|
||||||
if (evidenceItem.commentary) {
|
|
||||||
const commentaryHtml = escapeHtml(evidenceItem.commentary);
|
|
||||||
lines.push(
|
|
||||||
` <div class="citation-card__commentary">${commentaryHtml}</div>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lines.push("</aside>");
|
|
||||||
|
|
||||||
return lines.join("\n") + "\n";
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export {
|
|
||||||
renderCitationCardMarkdown,
|
|
||||||
type RenderCitationCardMarkdownInput,
|
|
||||||
} from "./markdown";
|
|
||||||
export {
|
|
||||||
renderCitationCardHtml,
|
|
||||||
type RenderCitationCardHtmlInput,
|
|
||||||
} from "./html";
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* Markdown citation card renderer tests (CE-WP-0004-T02).
|
|
||||||
*
|
|
||||||
* Snapshots lock the output format defined in
|
|
||||||
* `docs/decisions/ADR-0007-citation-card-format.md`. If a snapshot
|
|
||||||
* intentionally changes, update ADR-0007 in the same commit so the
|
|
||||||
* written contract and the runtime stay in sync.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import type { Document } from "@shared/document";
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
|
||||||
import type {
|
|
||||||
AnnotationId,
|
|
||||||
DocumentId,
|
|
||||||
EvidenceItemId,
|
|
||||||
RepresentationId,
|
|
||||||
} from "@shared/ids";
|
|
||||||
|
|
||||||
import { renderCitationCardMarkdown } from "./markdown";
|
|
||||||
|
|
||||||
const DOC_ID = "doc_2024-order" as DocumentId;
|
|
||||||
const REP_ID = "rep_2024-order" as RepresentationId;
|
|
||||||
const ANN_ID = "ann_para3" as AnnotationId;
|
|
||||||
const EV_ID = "ev_para3" as EvidenceItemId;
|
|
||||||
|
|
||||||
function makeDoc(overrides: Partial<Document> = {}): Document {
|
|
||||||
return {
|
|
||||||
id: DOC_ID,
|
|
||||||
title: "Order from 14 Mar 2024",
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
|
|
||||||
return {
|
|
||||||
id: ANN_ID,
|
|
||||||
documentId: DOC_ID,
|
|
||||||
representationId: REP_ID,
|
|
||||||
selectors: [],
|
|
||||||
quote: "Die Frist endet am 31. März 2024.",
|
|
||||||
normalizeVersion: 1,
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
|
|
||||||
return {
|
|
||||||
id: EV_ID,
|
|
||||||
annotationIds: [ANN_ID],
|
|
||||||
status: "candidate",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("renderCitationCardMarkdown()", () => {
|
|
||||||
it("renders quote + attribution + commentary", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"> Die Frist endet am 31. März 2024.
|
|
||||||
|
|
||||||
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
|
||||||
|
|
||||||
Deadline clause for the buyer.
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the commentary paragraph when none is set", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"> Die Frist endet am 31. März 2024.
|
|
||||||
|
|
||||||
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves multi-line quotes by prefixing each line with '> '", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn({ quote: "Line one.\nLine two.\nLine three." }),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"> Line one.
|
|
||||||
> Line two.
|
|
||||||
> Line three.
|
|
||||||
|
|
||||||
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to metadata.filename when document.title is missing", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: {
|
|
||||||
id: DOC_ID,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
metadata: { filename: "order-2024.pdf" },
|
|
||||||
},
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"> Die Frist endet am 31. März 2024.
|
|
||||||
|
|
||||||
— *order-2024.pdf* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes * and _ in the source label to keep italics intact", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc({ title: "Order *2024_v2*" }),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"> Die Frist endet am 31. März 2024.
|
|
||||||
|
|
||||||
— *Order \\*2024\\_v2\\** · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the open-context link when override is empty", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
openContextUrlOverride: "",
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
"> Die Frist endet am 31. März 2024.
|
|
||||||
|
|
||||||
— *Order from 14 Mar 2024*
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honours an openContextUrlOverride for mounted-prefix hosts", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: makeAnn(),
|
|
||||||
openContextUrlOverride: "https://citations.example.test/v?d=foo&a=bar",
|
|
||||||
});
|
|
||||||
expect(out).toContain(
|
|
||||||
"[Open source](https://citations.example.test/v?d=foo&a=bar)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders an empty blockquote line when the annotation has no quote", () => {
|
|
||||||
const out = renderCitationCardMarkdown({
|
|
||||||
evidenceItem: makeEv(),
|
|
||||||
document: makeDoc(),
|
|
||||||
annotation: {
|
|
||||||
id: ANN_ID,
|
|
||||||
documentId: DOC_ID,
|
|
||||||
representationId: REP_ID,
|
|
||||||
selectors: [],
|
|
||||||
normalizeVersion: 1,
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(out).toMatchInlineSnapshot(`
|
|
||||||
">
|
|
||||||
|
|
||||||
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Markdown citation card renderer.
|
|
||||||
*
|
|
||||||
* Implements CE-WP-0004-T02. Output format and rules are locked in
|
|
||||||
* `docs/decisions/ADR-0007-citation-card-format.md`.
|
|
||||||
*
|
|
||||||
* Inputs are passed by value (not looked up from a service) so this
|
|
||||||
* renderer can be unit-tested without an engine and reused by future
|
|
||||||
* batch-export jobs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import { resolveSourceLabel } from "@shared/citation-card-source";
|
|
||||||
import type { Document } from "@shared/document";
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
|
||||||
import { openContextUrl } from "@shared/open-context-url";
|
|
||||||
|
|
||||||
export interface RenderCitationCardMarkdownInput {
|
|
||||||
readonly evidenceItem: EvidenceItem;
|
|
||||||
readonly document: Document;
|
|
||||||
readonly annotation: Annotation;
|
|
||||||
/**
|
|
||||||
* Override the deep-link URL — useful for hosts that mount the viewer
|
|
||||||
* under a non-default prefix. Defaults to `openContextUrl({…})`.
|
|
||||||
*/
|
|
||||||
readonly openContextUrlOverride?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape `*` and `_` inside the source label so it doesn't accidentally
|
|
||||||
* break out of the surrounding `*…*` italic span. No other escaping is
|
|
||||||
* performed — citations should reproduce the source text verbatim.
|
|
||||||
*/
|
|
||||||
function escapeSourceLabel(label: string): string {
|
|
||||||
return label.replace(/[*_]/g, (m) => `\\${m}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderCitationCardMarkdown({
|
|
||||||
evidenceItem,
|
|
||||||
document,
|
|
||||||
annotation,
|
|
||||||
openContextUrlOverride,
|
|
||||||
}: RenderCitationCardMarkdownInput): string {
|
|
||||||
const quote = annotation.quote ?? "";
|
|
||||||
const sourceLabel = resolveSourceLabel(document);
|
|
||||||
|
|
||||||
const url =
|
|
||||||
openContextUrlOverride !== undefined
|
|
||||||
? openContextUrlOverride
|
|
||||||
: openContextUrl({
|
|
||||||
documentId: document.id,
|
|
||||||
annotationId: annotation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const quoteLines = quote.length === 0 ? [""] : quote.split("\n");
|
|
||||||
// Trailing whitespace on a blockquote line can be misread as a hard
|
|
||||||
// line break (`<br>`) by some Markdown engines; trimEnd keeps an
|
|
||||||
// empty line as bare `>`.
|
|
||||||
const quoteBlock = quoteLines.map((line) => `> ${line}`.trimEnd()).join("\n");
|
|
||||||
|
|
||||||
const attributionLink = url.length > 0 ? ` · [Open source](${url})` : "";
|
|
||||||
const attribution = `— *${escapeSourceLabel(sourceLabel)}*${attributionLink}`;
|
|
||||||
|
|
||||||
const sections = [quoteBlock, attribution];
|
|
||||||
if (evidenceItem.commentary) sections.push(evidenceItem.commentary);
|
|
||||||
|
|
||||||
return sections.join("\n\n") + "\n";
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory `Map`-backed `SessionRepository`.
|
|
||||||
*
|
|
||||||
* Sister to `in-memory.ts` but for `Session` objects. The session repo
|
|
||||||
* lives *outside* the per-session `Engine` (one repo for the whole app;
|
|
||||||
* each session's engine snapshot is keyed by `session.id`). Keeping it
|
|
||||||
* in the same `engine/repos` directory keeps the storage layer
|
|
||||||
* conventions together so the eventual ADR-0005 swap touches one
|
|
||||||
* neighbourhood.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SessionId } from "@shared/ids";
|
|
||||||
import type { Session } from "@shared/session";
|
|
||||||
|
|
||||||
export interface SessionRepository {
|
|
||||||
create(session: Session): Session;
|
|
||||||
get(id: SessionId): Session | null;
|
|
||||||
list(): readonly Session[];
|
|
||||||
update(session: Session): Session;
|
|
||||||
delete(id: SessionId): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInMemorySessionRepository(): SessionRepository {
|
|
||||||
const sessions = new Map<SessionId, Session>();
|
|
||||||
return {
|
|
||||||
create(session) {
|
|
||||||
sessions.set(session.id, session);
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return sessions.get(id) ?? null;
|
|
||||||
},
|
|
||||||
list() {
|
|
||||||
return [...sessions.values()];
|
|
||||||
},
|
|
||||||
update(session) {
|
|
||||||
if (!sessions.has(session.id)) {
|
|
||||||
throw new Error(`SessionRepository.update: unknown id ${session.id}`);
|
|
||||||
}
|
|
||||||
sessions.set(session.id, session);
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
delete(id) {
|
|
||||||
return sessions.delete(id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory `Map`-backed repositories.
|
|
||||||
*
|
|
||||||
* Implements the MVP storage layer. The repository interfaces match the
|
|
||||||
* shape that ADR-0005's eventual persistence implementation will satisfy,
|
|
||||||
* so swapping `createInMemoryRepos()` for a SQLite/Postgres factory later
|
|
||||||
* is a localised change.
|
|
||||||
*
|
|
||||||
* All mutating methods return the *stored* object so callers can pick up
|
|
||||||
* server-assigned fields (none in MVP, but the contract anticipates it).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
|
||||||
import type {
|
|
||||||
AnnotationId,
|
|
||||||
DocumentId,
|
|
||||||
EvidenceItemId,
|
|
||||||
RepresentationId,
|
|
||||||
} from "@shared/ids";
|
|
||||||
|
|
||||||
export interface DocumentRepository {
|
|
||||||
create(document: Document): Document;
|
|
||||||
get(id: DocumentId): Document | null;
|
|
||||||
list(): readonly Document[];
|
|
||||||
update(document: Document): Document;
|
|
||||||
delete(id: DocumentId): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepresentationRepository {
|
|
||||||
create(representation: DocumentRepresentation): DocumentRepresentation;
|
|
||||||
get(id: RepresentationId): DocumentRepresentation | null;
|
|
||||||
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
|
|
||||||
deleteByDocument(documentId: DocumentId): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnotationRepository {
|
|
||||||
create(annotation: Annotation): Annotation;
|
|
||||||
get(id: AnnotationId): Annotation | null;
|
|
||||||
listByDocument(documentId: DocumentId): readonly Annotation[];
|
|
||||||
update(annotation: Annotation): Annotation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceItemRepository {
|
|
||||||
create(item: EvidenceItem): EvidenceItem;
|
|
||||||
get(id: EvidenceItemId): EvidenceItem | null;
|
|
||||||
listByDocument(
|
|
||||||
documentId: DocumentId,
|
|
||||||
annotationLookup: (id: AnnotationId) => Annotation | null,
|
|
||||||
): readonly EvidenceItem[];
|
|
||||||
update(item: EvidenceItem): EvidenceItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InMemoryRepos {
|
|
||||||
readonly documents: DocumentRepository;
|
|
||||||
readonly representations: RepresentationRepository;
|
|
||||||
readonly annotations: AnnotationRepository;
|
|
||||||
readonly evidenceItems: EvidenceItemRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInMemoryRepos(): InMemoryRepos {
|
|
||||||
const documents = new Map<DocumentId, Document>();
|
|
||||||
const representations = new Map<RepresentationId, DocumentRepresentation>();
|
|
||||||
const annotations = new Map<AnnotationId, Annotation>();
|
|
||||||
const evidenceItems = new Map<EvidenceItemId, EvidenceItem>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents: {
|
|
||||||
create(document) {
|
|
||||||
documents.set(document.id, document);
|
|
||||||
return document;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return documents.get(id) ?? null;
|
|
||||||
},
|
|
||||||
list() {
|
|
||||||
return [...documents.values()];
|
|
||||||
},
|
|
||||||
update(document) {
|
|
||||||
if (!documents.has(document.id)) {
|
|
||||||
throw new Error(`DocumentRepository.update: unknown id ${document.id}`);
|
|
||||||
}
|
|
||||||
documents.set(document.id, document);
|
|
||||||
return document;
|
|
||||||
},
|
|
||||||
delete(id) {
|
|
||||||
return documents.delete(id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
representations: {
|
|
||||||
create(representation) {
|
|
||||||
representations.set(representation.id, representation);
|
|
||||||
return representation;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return representations.get(id) ?? null;
|
|
||||||
},
|
|
||||||
listByDocument(documentId) {
|
|
||||||
const out: DocumentRepresentation[] = [];
|
|
||||||
for (const rep of representations.values()) {
|
|
||||||
if (rep.documentId === documentId) out.push(rep);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
deleteByDocument(documentId) {
|
|
||||||
let removed = 0;
|
|
||||||
for (const [id, rep] of representations) {
|
|
||||||
if (rep.documentId === documentId) {
|
|
||||||
representations.delete(id);
|
|
||||||
removed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
annotations: {
|
|
||||||
create(annotation) {
|
|
||||||
annotations.set(annotation.id, annotation);
|
|
||||||
return annotation;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return annotations.get(id) ?? null;
|
|
||||||
},
|
|
||||||
listByDocument(documentId) {
|
|
||||||
const out: Annotation[] = [];
|
|
||||||
for (const ann of annotations.values()) {
|
|
||||||
if (ann.documentId === documentId) out.push(ann);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
update(annotation) {
|
|
||||||
if (!annotations.has(annotation.id)) {
|
|
||||||
throw new Error(`AnnotationRepository.update: unknown id ${annotation.id}`);
|
|
||||||
}
|
|
||||||
annotations.set(annotation.id, annotation);
|
|
||||||
return annotation;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
evidenceItems: {
|
|
||||||
create(item) {
|
|
||||||
evidenceItems.set(item.id, item);
|
|
||||||
return item;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return evidenceItems.get(id) ?? null;
|
|
||||||
},
|
|
||||||
listByDocument(documentId, annotationLookup) {
|
|
||||||
const out: EvidenceItem[] = [];
|
|
||||||
for (const item of evidenceItems.values()) {
|
|
||||||
if (item.annotationIds.some((aid) => annotationLookup(aid)?.documentId === documentId)) {
|
|
||||||
out.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
update(item) {
|
|
||||||
if (!evidenceItems.has(item.id)) {
|
|
||||||
throw new Error(`EvidenceItemRepository.update: unknown id ${item.id}`);
|
|
||||||
}
|
|
||||||
evidenceItems.set(item.id, item);
|
|
||||||
return item;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export {
|
|
||||||
createInMemoryRepos,
|
|
||||||
type InMemoryRepos,
|
|
||||||
type DocumentRepository,
|
|
||||||
type RepresentationRepository,
|
|
||||||
type AnnotationRepository,
|
|
||||||
type EvidenceItemRepository,
|
|
||||||
} from "./in-memory";
|
|
||||||
export {
|
|
||||||
createInMemorySessionRepository,
|
|
||||||
type SessionRepository,
|
|
||||||
} from "./in-memory-sessions";
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Annotation service — creates technical marks on document ranges and
|
|
||||||
* emits `AnnotationCreated`. Resolution-status updates emit
|
|
||||||
* `AnnotationResolved` / `AnnotationResolutionFailed`.
|
|
||||||
*
|
|
||||||
* Annotation creation is the engine's response to a user action in the
|
|
||||||
* viewer (T07). The viewer adapter has already turned the selection into
|
|
||||||
* `Selector[]`; this service stamps an ID, normalize-version, timestamps,
|
|
||||||
* persists, and broadcasts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
Annotation,
|
|
||||||
AnnotationResolutionStatus,
|
|
||||||
} from "@shared/annotation";
|
|
||||||
import type { DocumentId, RepresentationId, AnnotationId } from "@shared/ids";
|
|
||||||
import type { Selector } from "@shared/selector";
|
|
||||||
import { newId } from "@shared/ids";
|
|
||||||
import { NORMALIZE_VERSION } from "@shared/text/normalize";
|
|
||||||
|
|
||||||
import type { EventBus } from "../events";
|
|
||||||
import type { AnnotationRepository } from "../repos";
|
|
||||||
|
|
||||||
export interface CreateAnnotationInput {
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly representationId?: RepresentationId;
|
|
||||||
readonly selectors: readonly Selector[];
|
|
||||||
readonly quote?: string;
|
|
||||||
readonly note?: string;
|
|
||||||
readonly createdBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnotationService {
|
|
||||||
create(input: CreateAnnotationInput): Annotation;
|
|
||||||
get(id: AnnotationId): Annotation | null;
|
|
||||||
listByDocument(documentId: DocumentId): readonly Annotation[];
|
|
||||||
setResolutionStatus(
|
|
||||||
id: AnnotationId,
|
|
||||||
status: AnnotationResolutionStatus,
|
|
||||||
opts: { readonly confidence: number; readonly reason?: string },
|
|
||||||
): Annotation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAnnotationService(
|
|
||||||
annotations: AnnotationRepository,
|
|
||||||
bus: EventBus,
|
|
||||||
now: () => string = () => new Date().toISOString(),
|
|
||||||
): AnnotationService {
|
|
||||||
return {
|
|
||||||
create(input) {
|
|
||||||
const ts = now();
|
|
||||||
const annotation: Annotation = {
|
|
||||||
id: newId("annotation"),
|
|
||||||
documentId: input.documentId,
|
|
||||||
...(input.representationId !== undefined ? { representationId: input.representationId } : {}),
|
|
||||||
selectors: input.selectors,
|
|
||||||
...(input.quote !== undefined ? { quote: input.quote } : {}),
|
|
||||||
...(input.note !== undefined ? { note: input.note } : {}),
|
|
||||||
normalizeVersion: NORMALIZE_VERSION,
|
|
||||||
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
|
|
||||||
createdAt: ts,
|
|
||||||
updatedAt: ts,
|
|
||||||
};
|
|
||||||
const stored = annotations.create(annotation);
|
|
||||||
bus.emit({ type: "AnnotationCreated", annotationId: stored.id, annotation: stored });
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return annotations.get(id);
|
|
||||||
},
|
|
||||||
listByDocument(documentId) {
|
|
||||||
return annotations.listByDocument(documentId);
|
|
||||||
},
|
|
||||||
setResolutionStatus(id, status, opts) {
|
|
||||||
const existing = annotations.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`AnnotationService.setResolutionStatus: unknown id ${id}`);
|
|
||||||
}
|
|
||||||
const updated: Annotation = {
|
|
||||||
...existing,
|
|
||||||
resolutionStatus: status,
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
const stored = annotations.update(updated);
|
|
||||||
if (status === "unresolved" || status === "stale") {
|
|
||||||
bus.emit({
|
|
||||||
type: "AnnotationResolutionFailed",
|
|
||||||
annotationId: stored.id,
|
|
||||||
reason: opts.reason ?? status,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
bus.emit({
|
|
||||||
type: "AnnotationResolved",
|
|
||||||
annotationId: stored.id,
|
|
||||||
status,
|
|
||||||
confidence: opts.confidence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* Document service — registers ingested documents and emits the §4 events.
|
|
||||||
*
|
|
||||||
* The ingest pipeline (`src/source/pdf/ingest.ts`) is a pure function over
|
|
||||||
* bytes — it does not touch the engine. The app composition root calls
|
|
||||||
* `ingestPdf` then hands the result to `documentService.register()`, which
|
|
||||||
* is where the engine takes over: persist into the repos, emit
|
|
||||||
* `DocumentImported` + `DocumentRepresentationGenerated`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
|
||||||
|
|
||||||
import type { EventBus } from "../events";
|
|
||||||
import type { DocumentRepository, RepresentationRepository } from "../repos";
|
|
||||||
|
|
||||||
export interface DocumentService {
|
|
||||||
register(input: {
|
|
||||||
readonly document: Document;
|
|
||||||
readonly representation: DocumentRepresentation;
|
|
||||||
}): { readonly document: Document; readonly representation: DocumentRepresentation };
|
|
||||||
get(id: DocumentId): Document | null;
|
|
||||||
list(): readonly Document[];
|
|
||||||
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
|
|
||||||
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
|
|
||||||
remove(id: DocumentId): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDocumentService(
|
|
||||||
documents: DocumentRepository,
|
|
||||||
representations: RepresentationRepository,
|
|
||||||
bus: EventBus,
|
|
||||||
): DocumentService {
|
|
||||||
return {
|
|
||||||
register({ document, representation }) {
|
|
||||||
const storedDocument = documents.create(document);
|
|
||||||
const storedRepresentation = representations.create(representation);
|
|
||||||
bus.emit({
|
|
||||||
type: "DocumentImported",
|
|
||||||
documentId: storedDocument.id,
|
|
||||||
document: storedDocument,
|
|
||||||
});
|
|
||||||
bus.emit({
|
|
||||||
type: "DocumentRepresentationGenerated",
|
|
||||||
documentId: storedDocument.id,
|
|
||||||
representationId: storedRepresentation.id,
|
|
||||||
representation: storedRepresentation,
|
|
||||||
});
|
|
||||||
return { document: storedDocument, representation: storedRepresentation };
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return documents.get(id);
|
|
||||||
},
|
|
||||||
list() {
|
|
||||||
return documents.list();
|
|
||||||
},
|
|
||||||
getRepresentation(id) {
|
|
||||||
return representations.get(id);
|
|
||||||
},
|
|
||||||
listRepresentations(documentId) {
|
|
||||||
return representations.listByDocument(documentId);
|
|
||||||
},
|
|
||||||
remove(id) {
|
|
||||||
const existing = documents.get(id);
|
|
||||||
if (!existing) return false;
|
|
||||||
representations.deleteByDocument(id);
|
|
||||||
const removed = documents.delete(id);
|
|
||||||
if (removed) {
|
|
||||||
bus.emit({ type: "DocumentRemoved", documentId: id });
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* Evidence service — creates EvidenceItems on top of annotations and
|
|
||||||
* tracks their lifecycle. Emits §4 events: `EvidenceItemCreated`,
|
|
||||||
* `EvidenceItemUpdated`, `EvidenceItemActivated`.
|
|
||||||
*
|
|
||||||
* MVP item shape per `wiki/SharedContracts.md` §2.2: status starts at
|
|
||||||
* `candidate`, may transition to `confirmed | rejected | needs-check`.
|
|
||||||
* Item-level relation/strength (supports/contradicts/...) lives on the
|
|
||||||
* link, not the item — that's CE-WP-0003.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Annotation } from "@shared/annotation";
|
|
||||||
import type {
|
|
||||||
EvidenceItem,
|
|
||||||
EvidenceItemStatus,
|
|
||||||
} from "@shared/evidence";
|
|
||||||
import type {
|
|
||||||
AnnotationId,
|
|
||||||
DocumentId,
|
|
||||||
EvidenceItemId,
|
|
||||||
} from "@shared/ids";
|
|
||||||
import { newId } from "@shared/ids";
|
|
||||||
|
|
||||||
import type { EventBus, EvidenceItemActivatedEvent } from "../events";
|
|
||||||
import type { EvidenceItemRepository } from "../repos";
|
|
||||||
|
|
||||||
export interface CreateEvidenceItemInput {
|
|
||||||
readonly annotationIds: readonly AnnotationId[];
|
|
||||||
readonly title?: string;
|
|
||||||
readonly commentary?: string;
|
|
||||||
readonly status?: EvidenceItemStatus;
|
|
||||||
readonly confidence?: number;
|
|
||||||
readonly tags?: readonly string[];
|
|
||||||
readonly createdBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceService {
|
|
||||||
create(input: CreateEvidenceItemInput): EvidenceItem;
|
|
||||||
get(id: EvidenceItemId): EvidenceItem | null;
|
|
||||||
listByDocument(documentId: DocumentId): readonly EvidenceItem[];
|
|
||||||
setStatus(id: EvidenceItemId, status: EvidenceItemStatus): EvidenceItem;
|
|
||||||
updateCommentary(id: EvidenceItemId, commentary: string): EvidenceItem;
|
|
||||||
activate(
|
|
||||||
id: EvidenceItemId,
|
|
||||||
source?: EvidenceItemActivatedEvent["source"],
|
|
||||||
): EvidenceItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEvidenceService(
|
|
||||||
items: EvidenceItemRepository,
|
|
||||||
annotationLookup: (id: AnnotationId) => Annotation | null,
|
|
||||||
bus: EventBus,
|
|
||||||
now: () => string = () => new Date().toISOString(),
|
|
||||||
): EvidenceService {
|
|
||||||
return {
|
|
||||||
create(input) {
|
|
||||||
if (input.annotationIds.length === 0) {
|
|
||||||
throw new Error("EvidenceService.create: at least one annotationId is required");
|
|
||||||
}
|
|
||||||
const ts = now();
|
|
||||||
const item: EvidenceItem = {
|
|
||||||
id: newId("evidence"),
|
|
||||||
annotationIds: input.annotationIds,
|
|
||||||
...(input.title !== undefined ? { title: input.title } : {}),
|
|
||||||
...(input.commentary !== undefined ? { commentary: input.commentary } : {}),
|
|
||||||
status: input.status ?? "candidate",
|
|
||||||
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
|
||||||
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
|
||||||
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
|
|
||||||
createdAt: ts,
|
|
||||||
updatedAt: ts,
|
|
||||||
};
|
|
||||||
const stored = items.create(item);
|
|
||||||
bus.emit({ type: "EvidenceItemCreated", evidenceItemId: stored.id, evidenceItem: stored });
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return items.get(id);
|
|
||||||
},
|
|
||||||
listByDocument(documentId) {
|
|
||||||
return items.listByDocument(documentId, annotationLookup);
|
|
||||||
},
|
|
||||||
setStatus(id, status) {
|
|
||||||
const existing = items.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`EvidenceService.setStatus: unknown id ${id}`);
|
|
||||||
}
|
|
||||||
if (existing.status === status) return existing;
|
|
||||||
const updated: EvidenceItem = { ...existing, status, updatedAt: now() };
|
|
||||||
const stored = items.update(updated);
|
|
||||||
bus.emit({
|
|
||||||
type: "EvidenceItemUpdated",
|
|
||||||
evidenceItemId: stored.id,
|
|
||||||
evidenceItem: stored,
|
|
||||||
previousStatus: existing.status,
|
|
||||||
});
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
updateCommentary(id, commentary) {
|
|
||||||
const existing = items.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`EvidenceService.updateCommentary: unknown id ${id}`);
|
|
||||||
}
|
|
||||||
const updated: EvidenceItem = { ...existing, commentary, updatedAt: now() };
|
|
||||||
const stored = items.update(updated);
|
|
||||||
bus.emit({
|
|
||||||
type: "EvidenceItemUpdated",
|
|
||||||
evidenceItemId: stored.id,
|
|
||||||
evidenceItem: stored,
|
|
||||||
previousStatus: existing.status,
|
|
||||||
});
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
activate(id, source) {
|
|
||||||
const existing = items.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`EvidenceService.activate: unknown id ${id}`);
|
|
||||||
}
|
|
||||||
bus.emit({
|
|
||||||
type: "EvidenceItemActivated",
|
|
||||||
evidenceItemId: existing.id,
|
|
||||||
...(source !== undefined ? { source } : {}),
|
|
||||||
});
|
|
||||||
return existing;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export {
|
|
||||||
createDocumentService,
|
|
||||||
type DocumentService,
|
|
||||||
} from "./documents";
|
|
||||||
export {
|
|
||||||
createAnnotationService,
|
|
||||||
type AnnotationService,
|
|
||||||
type CreateAnnotationInput,
|
|
||||||
} from "./annotations";
|
|
||||||
export {
|
|
||||||
createEvidenceService,
|
|
||||||
type EvidenceService,
|
|
||||||
type CreateEvidenceItemInput,
|
|
||||||
} from "./evidence";
|
|
||||||
export {
|
|
||||||
ACTIVE_SESSION_KEY,
|
|
||||||
attachSessionPersister,
|
|
||||||
createSessionService,
|
|
||||||
DuplicateSessionNameError,
|
|
||||||
engineSnapshotKey,
|
|
||||||
restoreSessionsFromStorage,
|
|
||||||
SESSIONS_INDEX_KEY,
|
|
||||||
type CreateSessionInput,
|
|
||||||
type RestoreSessionsResult,
|
|
||||||
type SessionPersisterOptions,
|
|
||||||
type SessionService,
|
|
||||||
} from "./sessions";
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { SessionId } from "@shared/ids";
|
|
||||||
import type { Session } from "@shared/session";
|
|
||||||
|
|
||||||
import { createEventBus, type EventBus, type EngineEvent } from "../events";
|
|
||||||
import {
|
|
||||||
createInMemorySessionRepository,
|
|
||||||
type SessionRepository,
|
|
||||||
} from "../repos";
|
|
||||||
import {
|
|
||||||
ACTIVE_SESSION_KEY,
|
|
||||||
attachSessionPersister,
|
|
||||||
createSessionService,
|
|
||||||
DuplicateSessionNameError,
|
|
||||||
engineSnapshotKey,
|
|
||||||
restoreSessionsFromStorage,
|
|
||||||
SESSIONS_INDEX_KEY,
|
|
||||||
type SessionService,
|
|
||||||
} from "./sessions";
|
|
||||||
|
|
||||||
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
getItem: (k) => map.get(k) ?? null,
|
|
||||||
setItem: (k, v) => void map.set(k, v),
|
|
||||||
removeItem: (k) => void map.delete(k),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function freshService(): {
|
|
||||||
service: SessionService;
|
|
||||||
repo: SessionRepository;
|
|
||||||
bus: EventBus;
|
|
||||||
events: EngineEvent[];
|
|
||||||
} {
|
|
||||||
const repo = createInMemorySessionRepository();
|
|
||||||
const bus = createEventBus();
|
|
||||||
const events: EngineEvent[] = [];
|
|
||||||
bus.onAny((e) => events.push(e));
|
|
||||||
const service = createSessionService(repo, bus);
|
|
||||||
return { service, repo, bus, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("engineSnapshotKey", () => {
|
|
||||||
it("formats as citation-evidence:session:<id>:engine-snapshot:v1", () => {
|
|
||||||
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
|
|
||||||
"citation-evidence:session:sess_abc:engine-snapshot:v1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SessionService — lifecycle", () => {
|
|
||||||
let s: ReturnType<typeof freshService>;
|
|
||||||
beforeEach(() => {
|
|
||||||
s = freshService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a session and emits SessionCreated", () => {
|
|
||||||
const created = s.service.create("Lease 2024");
|
|
||||||
expect(created.name).toBe("Lease 2024");
|
|
||||||
expect(created.id).toMatch(/^sess_/);
|
|
||||||
expect(s.events).toHaveLength(1);
|
|
||||||
expect(s.events[0]!.type).toBe("SessionCreated");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims whitespace in names", () => {
|
|
||||||
const created = s.service.create(" Trimmed ");
|
|
||||||
expect(created.name).toBe("Trimmed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty names", () => {
|
|
||||||
expect(() => s.service.create(" ")).toThrow(/must not be empty/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects case-insensitive duplicates", () => {
|
|
||||||
s.service.create("Demo");
|
|
||||||
expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError);
|
|
||||||
expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rename emits SessionRenamed with previousName", () => {
|
|
||||||
const created = s.service.create("Old");
|
|
||||||
s.events.length = 0;
|
|
||||||
const renamed = s.service.rename(created.id, "New");
|
|
||||||
expect(renamed.name).toBe("New");
|
|
||||||
expect(s.events).toHaveLength(1);
|
|
||||||
const evt = s.events[0]!;
|
|
||||||
expect(evt.type).toBe("SessionRenamed");
|
|
||||||
if (evt.type === "SessionRenamed") {
|
|
||||||
expect(evt.previousName).toBe("Old");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rename rejects a duplicate (but allows renaming to your own current name)", () => {
|
|
||||||
const a = s.service.create("Alpha");
|
|
||||||
s.service.create("Beta");
|
|
||||||
expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError);
|
|
||||||
// self-rename is fine
|
|
||||||
const same = s.service.rename(a.id, "Alpha");
|
|
||||||
expect(same.name).toBe("Alpha");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delete emits SessionDeleted and clears active if it was the active one", () => {
|
|
||||||
const created = s.service.create("To Delete");
|
|
||||||
s.service.setActive(created.id);
|
|
||||||
s.events.length = 0;
|
|
||||||
const ok = s.service.delete(created.id);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
const types = s.events.map((e) => e.type);
|
|
||||||
expect(types).toContain("SessionActivated"); // active cleared first
|
|
||||||
expect(types).toContain("SessionDeleted");
|
|
||||||
expect(s.service.getActive()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delete on an unknown id is a no-op (returns false, no events)", () => {
|
|
||||||
const ok = s.service.delete("sess_missing" as SessionId);
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
expect(s.events).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setActive on an unknown id throws", () => {
|
|
||||||
expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setActive emits SessionActivated with previousSessionId", () => {
|
|
||||||
const a = s.service.create("A");
|
|
||||||
const b = s.service.create("B");
|
|
||||||
s.events.length = 0;
|
|
||||||
s.service.setActive(a.id);
|
|
||||||
s.service.setActive(b.id);
|
|
||||||
const activated = s.events.filter((e) => e.type === "SessionActivated");
|
|
||||||
expect(activated).toHaveLength(2);
|
|
||||||
if (activated[1]!.type === "SessionActivated") {
|
|
||||||
expect(activated[1]!.previousSessionId).toBe(a.id);
|
|
||||||
expect(activated[1]!.sessionId).toBe(b.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setActive to the same id is a no-op (no event)", () => {
|
|
||||||
const a = s.service.create("A");
|
|
||||||
s.service.setActive(a.id);
|
|
||||||
s.events.length = 0;
|
|
||||||
s.service.setActive(a.id);
|
|
||||||
expect(s.events).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setActive stamps lastOpenedAt", () => {
|
|
||||||
const a = s.service.create("A");
|
|
||||||
expect(a.lastOpenedAt).toBeUndefined();
|
|
||||||
s.service.setActive(a.id);
|
|
||||||
const reread = s.service.get(a.id);
|
|
||||||
expect(reread?.lastOpenedAt).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("attachSessionPersister + restoreSessionsFromStorage", () => {
|
|
||||||
it("round-trips a session index through storage", () => {
|
|
||||||
const storage = memoryStorage();
|
|
||||||
const src = freshService();
|
|
||||||
attachSessionPersister(src.service, src.bus, { storage });
|
|
||||||
|
|
||||||
const a = src.service.create("Alpha");
|
|
||||||
const b = src.service.create("Beta");
|
|
||||||
src.service.setActive(b.id);
|
|
||||||
|
|
||||||
// Read-back into a fresh service.
|
|
||||||
const dst = freshService();
|
|
||||||
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
|
|
||||||
expect(result.restored).toBe(true);
|
|
||||||
expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]);
|
|
||||||
expect(result.activeSessionId).toBe(b.id);
|
|
||||||
expect(dst.service.getActive()).toBe(b.id);
|
|
||||||
// a is still in the repo too
|
|
||||||
expect(dst.service.get(a.id)?.name).toBe("Alpha");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns {restored:false} when storage is empty", () => {
|
|
||||||
const storage = memoryStorage();
|
|
||||||
const dst = freshService();
|
|
||||||
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
|
|
||||||
expect(result.restored).toBe(false);
|
|
||||||
expect(result.sessions).toHaveLength(0);
|
|
||||||
expect(result.activeSessionId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delete clears both the index entry and the per-session snapshot key", () => {
|
|
||||||
const storage = memoryStorage();
|
|
||||||
const src = freshService();
|
|
||||||
attachSessionPersister(src.service, src.bus, { storage });
|
|
||||||
const created = src.service.create("Doomed");
|
|
||||||
// Pretend an engine snapshot was written by the per-session persister.
|
|
||||||
storage.setItem(engineSnapshotKey(created.id), "{}");
|
|
||||||
|
|
||||||
src.service.delete(created.id);
|
|
||||||
|
|
||||||
expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull();
|
|
||||||
const raw = storage.getItem(SESSIONS_INDEX_KEY);
|
|
||||||
expect(raw).not.toBeNull();
|
|
||||||
const parsed = JSON.parse(raw!);
|
|
||||||
expect(parsed.sessions).toHaveLength(0);
|
|
||||||
expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
/**
|
|
||||||
* SessionService — lifecycle for `Session` records.
|
|
||||||
*
|
|
||||||
* Lives *above* the per-session `Engine` (the engine itself is recreated
|
|
||||||
* each time the active session changes). The service owns its own
|
|
||||||
* `EventBus` instance — independent of any engine bus — but uses the
|
|
||||||
* same `EngineEvent` shape so consumers can subscribe with the standard
|
|
||||||
* `bus.on("SessionCreated", …)` pattern.
|
|
||||||
*
|
|
||||||
* Per-session engine snapshot persistence is handled by attaching the
|
|
||||||
* existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })`
|
|
||||||
* inside the app's `EngineProvider`. The helpers in this file own the
|
|
||||||
* *cross-session* storage: the session index + the active-session
|
|
||||||
* pointer.
|
|
||||||
*
|
|
||||||
* Naming rules:
|
|
||||||
* - Names are trimmed on input.
|
|
||||||
* - Case-insensitive uniqueness — two sessions cannot coexist with
|
|
||||||
* names that differ only in case ("Demo" vs "demo"). This avoids
|
|
||||||
* surprising the ZIP-merge path in T07, which uses `session.name`
|
|
||||||
* to find an existing target.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { newId } from "@shared/ids";
|
|
||||||
import type { SessionId } from "@shared/ids";
|
|
||||||
import type { Session } from "@shared/session";
|
|
||||||
|
|
||||||
import type { EventBus } from "../events";
|
|
||||||
import type { SessionRepository } from "../repos";
|
|
||||||
|
|
||||||
const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1";
|
|
||||||
const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1";
|
|
||||||
|
|
||||||
export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the engine-snapshot storage key for a given session.
|
|
||||||
*
|
|
||||||
* Format: `citation-evidence:session:<sessionId>:engine-snapshot:v1`.
|
|
||||||
* The `v1` tail leaves room for a future migration that changes the
|
|
||||||
* snapshot shape without sweeping the legacy keys.
|
|
||||||
*/
|
|
||||||
export function engineSnapshotKey(sessionId: SessionId): string {
|
|
||||||
return `citation-evidence:session:${sessionId}:engine-snapshot:v1`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DuplicateSessionNameError extends Error {
|
|
||||||
constructor(name: string) {
|
|
||||||
super(`Session with name "${name}" already exists`);
|
|
||||||
this.name = "DuplicateSessionNameError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateSessionInput {
|
|
||||||
readonly name: string;
|
|
||||||
/** Override the generated id — primarily for tests and importers. */
|
|
||||||
readonly id?: SessionId;
|
|
||||||
readonly now?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionService {
|
|
||||||
create(input: string | CreateSessionInput): Session;
|
|
||||||
rename(id: SessionId, name: string): Session;
|
|
||||||
delete(id: SessionId): boolean;
|
|
||||||
list(): readonly Session[];
|
|
||||||
get(id: SessionId): Session | null;
|
|
||||||
setActive(id: SessionId | null): void;
|
|
||||||
getActive(): SessionId | null;
|
|
||||||
/** Record an "I just opened this" timestamp on the session. */
|
|
||||||
touch(id: SessionId): Session | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowIso(now?: string): string {
|
|
||||||
return now ?? new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalisedName(name: string): { display: string; key: string } {
|
|
||||||
const display = name.trim();
|
|
||||||
return { display, key: display.toLocaleLowerCase() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSessionService(
|
|
||||||
repo: SessionRepository,
|
|
||||||
bus: EventBus,
|
|
||||||
): SessionService {
|
|
||||||
let activeId: SessionId | null = null;
|
|
||||||
|
|
||||||
function assertUniqueName(name: string, exceptId?: SessionId) {
|
|
||||||
const { key } = normalisedName(name);
|
|
||||||
for (const existing of repo.list()) {
|
|
||||||
if (exceptId && existing.id === exceptId) continue;
|
|
||||||
if (existing.name.trim().toLocaleLowerCase() === key) {
|
|
||||||
throw new DuplicateSessionNameError(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
create(input) {
|
|
||||||
const { name, id, now } =
|
|
||||||
typeof input === "string" ? { name: input, id: undefined, now: undefined } : input;
|
|
||||||
const { display } = normalisedName(name);
|
|
||||||
if (display.length === 0) {
|
|
||||||
throw new Error("SessionService.create: name must not be empty");
|
|
||||||
}
|
|
||||||
assertUniqueName(display);
|
|
||||||
const ts = nowIso(now);
|
|
||||||
const session: Session = {
|
|
||||||
id: id ?? newId("session"),
|
|
||||||
name: display,
|
|
||||||
createdAt: ts,
|
|
||||||
updatedAt: ts,
|
|
||||||
};
|
|
||||||
const stored = repo.create(session);
|
|
||||||
bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored });
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
rename(id, name) {
|
|
||||||
const existing = repo.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`SessionService.rename: unknown session ${id}`);
|
|
||||||
}
|
|
||||||
const { display } = normalisedName(name);
|
|
||||||
if (display.length === 0) {
|
|
||||||
throw new Error("SessionService.rename: name must not be empty");
|
|
||||||
}
|
|
||||||
assertUniqueName(display, id);
|
|
||||||
const previousName = existing.name;
|
|
||||||
const next: Session = {
|
|
||||||
...existing,
|
|
||||||
name: display,
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
};
|
|
||||||
const stored = repo.update(next);
|
|
||||||
bus.emit({
|
|
||||||
type: "SessionRenamed",
|
|
||||||
sessionId: stored.id,
|
|
||||||
session: stored,
|
|
||||||
previousName,
|
|
||||||
});
|
|
||||||
return stored;
|
|
||||||
},
|
|
||||||
delete(id) {
|
|
||||||
const removed = repo.delete(id);
|
|
||||||
if (removed) {
|
|
||||||
if (activeId === id) {
|
|
||||||
const previousSessionId = activeId;
|
|
||||||
activeId = null;
|
|
||||||
bus.emit({
|
|
||||||
type: "SessionActivated",
|
|
||||||
sessionId: null,
|
|
||||||
previousSessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bus.emit({ type: "SessionDeleted", sessionId: id });
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
},
|
|
||||||
list() {
|
|
||||||
return repo.list();
|
|
||||||
},
|
|
||||||
get(id) {
|
|
||||||
return repo.get(id);
|
|
||||||
},
|
|
||||||
setActive(id) {
|
|
||||||
if (id !== null && !repo.get(id)) {
|
|
||||||
throw new Error(`SessionService.setActive: unknown session ${id}`);
|
|
||||||
}
|
|
||||||
if (id === activeId) return;
|
|
||||||
const previousSessionId = activeId;
|
|
||||||
activeId = id;
|
|
||||||
if (id) {
|
|
||||||
const existing = repo.get(id);
|
|
||||||
if (existing) {
|
|
||||||
repo.update({ ...existing, lastOpenedAt: nowIso() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bus.emit({
|
|
||||||
type: "SessionActivated",
|
|
||||||
sessionId: id,
|
|
||||||
previousSessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getActive() {
|
|
||||||
return activeId;
|
|
||||||
},
|
|
||||||
touch(id) {
|
|
||||||
const existing = repo.get(id);
|
|
||||||
if (!existing) return null;
|
|
||||||
return repo.update({ ...existing, lastOpenedAt: nowIso() });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cross-session persistence (the session index + active-session pointer).
|
|
||||||
// Per-session engine snapshots are handled by `attachPersister` against
|
|
||||||
// `engineSnapshotKey(sessionId)`.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface SessionPersisterOptions {
|
|
||||||
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionsFile {
|
|
||||||
readonly version: 1;
|
|
||||||
readonly sessions: readonly Session[];
|
|
||||||
readonly activeSessionId: SessionId | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachSessionPersister(
|
|
||||||
service: SessionService,
|
|
||||||
bus: EventBus,
|
|
||||||
options: SessionPersisterOptions = {},
|
|
||||||
): () => void {
|
|
||||||
const storage = options.storage ?? globalThis.localStorage;
|
|
||||||
const writeIndex = () => {
|
|
||||||
const file: SessionsFile = {
|
|
||||||
version: 1,
|
|
||||||
sessions: service.list(),
|
|
||||||
activeSessionId: service.getActive(),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file));
|
|
||||||
if (file.activeSessionId) {
|
|
||||||
storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId);
|
|
||||||
} else {
|
|
||||||
storage.removeItem(ACTIVE_SESSION_KEY);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("attachSessionPersister: write failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const writeIndexAndCleanup = () => {
|
|
||||||
writeIndex();
|
|
||||||
};
|
|
||||||
const writeOnDelete = (sessionId: SessionId) => {
|
|
||||||
writeIndex();
|
|
||||||
try {
|
|
||||||
storage.removeItem(engineSnapshotKey(sessionId));
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("attachSessionPersister: snapshot cleanup failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const offs = [
|
|
||||||
bus.on("SessionCreated", writeIndexAndCleanup),
|
|
||||||
bus.on("SessionRenamed", writeIndexAndCleanup),
|
|
||||||
bus.on("SessionActivated", writeIndexAndCleanup),
|
|
||||||
bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)),
|
|
||||||
];
|
|
||||||
return () => {
|
|
||||||
for (const off of offs) off();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestoreSessionsResult {
|
|
||||||
readonly restored: boolean;
|
|
||||||
readonly sessions: readonly Session[];
|
|
||||||
readonly activeSessionId: SessionId | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate the session repo from storage *without* firing events. Mirrors
|
|
||||||
* `restoreSnapshot`'s "direct repo write" pattern so the persister
|
|
||||||
* (which is attached after restore) doesn't immediately re-write what
|
|
||||||
* we just read.
|
|
||||||
*/
|
|
||||||
export function restoreSessionsFromStorage(
|
|
||||||
repo: SessionRepository,
|
|
||||||
service: SessionService,
|
|
||||||
options: SessionPersisterOptions = {},
|
|
||||||
): RestoreSessionsResult {
|
|
||||||
const storage = options.storage ?? globalThis.localStorage;
|
|
||||||
const raw = storage.getItem(SESSIONS_INDEX_KEY);
|
|
||||||
if (!raw) return { restored: false, sessions: [], activeSessionId: null };
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as Partial<SessionsFile>;
|
|
||||||
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
|
|
||||||
return { restored: false, sessions: [], activeSessionId: null };
|
|
||||||
}
|
|
||||||
for (const s of parsed.sessions) repo.create(s);
|
|
||||||
const activeRaw =
|
|
||||||
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null;
|
|
||||||
const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY);
|
|
||||||
const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null;
|
|
||||||
let activeSessionId: SessionId | null = null;
|
|
||||||
if (candidate && repo.get(candidate)) {
|
|
||||||
activeSessionId = candidate;
|
|
||||||
// Use service.setActive to keep the in-memory activeId aligned —
|
|
||||||
// suppress the resulting event so we don't bounce the persister.
|
|
||||||
// The bus listener attached *after* restore is what does the
|
|
||||||
// persistence, so emitting here is harmless either way; but
|
|
||||||
// skipping it keeps restore strictly side-effect-free from the
|
|
||||||
// listener's point of view.
|
|
||||||
service.setActive(activeSessionId);
|
|
||||||
}
|
|
||||||
return { restored: true, sessions: repo.list(), activeSessionId };
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("restoreSessionsFromStorage: parse failed", err);
|
|
||||||
return { restored: false, sessions: [], activeSessionId: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-session engine snapshot round-trip.
|
|
||||||
*
|
|
||||||
* The workplan (CE-WP-0005-T01) requires that two sessions persisted
|
|
||||||
* under the per-session key scheme can each be restored independently
|
|
||||||
* — proving the storage layout actually partitions data by session.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
|
||||||
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
|
|
||||||
|
|
||||||
import {
|
|
||||||
attachPersister,
|
|
||||||
createEngine,
|
|
||||||
engineSnapshotKey,
|
|
||||||
restoreFromStorage,
|
|
||||||
type Engine,
|
|
||||||
type EngineSnapshot,
|
|
||||||
} from "./index";
|
|
||||||
|
|
||||||
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
getItem: (k) => map.get(k) ?? null,
|
|
||||||
setItem: (k, v) => void map.set(k, v),
|
|
||||||
removeItem: (k) => void map.delete(k),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedDoc(engine: Engine, label: string): { id: DocumentId } {
|
|
||||||
const id = `doc_${label}` as DocumentId;
|
|
||||||
const repId = `rep_${label}` as RepresentationId;
|
|
||||||
const document: Document = {
|
|
||||||
id,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
title: `Doc ${label}`,
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
const representation: DocumentRepresentation = {
|
|
||||||
id: repId,
|
|
||||||
documentId: id,
|
|
||||||
representationType: "pdf-text",
|
|
||||||
contentHash: `hash-${label}`,
|
|
||||||
canonicalText: `text for ${label}`,
|
|
||||||
pageMap: [{ page: 1, width: 100, height: 100 }],
|
|
||||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }],
|
|
||||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
engine.documents.register({ document, representation });
|
|
||||||
return { id };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("per-session engine snapshot round-trip", () => {
|
|
||||||
it("keeps two sessions' snapshots isolated under per-session storage keys", () => {
|
|
||||||
const storage = memoryStorage();
|
|
||||||
const sessA = "sess_aaa" as SessionId;
|
|
||||||
const sessB = "sess_bbb" as SessionId;
|
|
||||||
|
|
||||||
// Author session A.
|
|
||||||
const engineA = createEngine();
|
|
||||||
const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage });
|
|
||||||
const a1 = seedDoc(engineA, "a1");
|
|
||||||
const a2 = seedDoc(engineA, "a2");
|
|
||||||
offA();
|
|
||||||
|
|
||||||
// Author session B with completely different documents.
|
|
||||||
const engineB = createEngine();
|
|
||||||
const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage });
|
|
||||||
const b1 = seedDoc(engineB, "b1");
|
|
||||||
offB();
|
|
||||||
|
|
||||||
// Restore each into its own fresh engine; assert isolation.
|
|
||||||
const restoredA = createEngine();
|
|
||||||
const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage });
|
|
||||||
expect(resA.restored).toBe(true);
|
|
||||||
const aIds = restoredA.documents.list().map((d) => d.id).sort();
|
|
||||||
expect(aIds).toEqual([a1.id, a2.id].sort());
|
|
||||||
|
|
||||||
const restoredB = createEngine();
|
|
||||||
const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage });
|
|
||||||
expect(resB.restored).toBe(true);
|
|
||||||
const bIds = restoredB.documents.list().map((d) => d.id);
|
|
||||||
expect(bIds).toEqual([b1.id]);
|
|
||||||
|
|
||||||
// Sanity: each snapshot key really does hold a distinct snapshot.
|
|
||||||
const rawA = storage.getItem(engineSnapshotKey(sessA));
|
|
||||||
const rawB = storage.getItem(engineSnapshotKey(sessB));
|
|
||||||
expect(rawA).not.toBeNull();
|
|
||||||
expect(rawB).not.toBeNull();
|
|
||||||
const snapA = JSON.parse(rawA!) as EngineSnapshot;
|
|
||||||
const snapB = JSON.parse(rawB!) as EngineSnapshot;
|
|
||||||
expect(snapA.documents).toHaveLength(2);
|
|
||||||
expect(snapB.documents).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* The Annotation type.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.1 (resolutionStatus)
|
|
||||||
* and `wiki/ArchitectureOverview.md` §4.4. Annotations are the *technical*
|
|
||||||
* mark on a document range — meaning and commentary live on EvidenceItem.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AnnotationId, DocumentId, RepresentationId } from "./ids";
|
|
||||||
import type { Selector } from "./selector";
|
|
||||||
|
|
||||||
/** Closed enum per `wiki/SharedContracts.md` §2.1. */
|
|
||||||
export type AnnotationResolutionStatus =
|
|
||||||
| "resolved"
|
|
||||||
| "ambiguous"
|
|
||||||
| "unresolved"
|
|
||||||
| "stale";
|
|
||||||
|
|
||||||
export interface Annotation {
|
|
||||||
readonly id: AnnotationId;
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly representationId?: RepresentationId;
|
|
||||||
/**
|
|
||||||
* All available selectors for this passage, in order of expected
|
|
||||||
* resolution confidence. Per the §3 redundancy rule, the system stores
|
|
||||||
* every selector kind it could derive at capture time.
|
|
||||||
*/
|
|
||||||
readonly selectors: readonly Selector[];
|
|
||||||
/** Verbatim canonical text at capture time. */
|
|
||||||
readonly quote?: string;
|
|
||||||
/** Short human note attached to the technical mark. */
|
|
||||||
readonly note?: string;
|
|
||||||
/**
|
|
||||||
* Version of `normalize()` that was active when these selectors were
|
|
||||||
* stored. Recorded so future normalization changes can be detected as a
|
|
||||||
* migration concern. See `src/shared/text/normalize.ts`.
|
|
||||||
*/
|
|
||||||
readonly normalizeVersion: number;
|
|
||||||
readonly resolutionStatus?: AnnotationResolutionStatus;
|
|
||||||
readonly createdBy?: string;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly createdAt: string;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for `resolveSourceLabel()`. Locks the precedence order
|
|
||||||
* described in workplan CE-WP-0004-T02:
|
|
||||||
* title → metadata.filename → uri → id.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { resolveSourceLabel } from "./citation-card-source";
|
|
||||||
import type { Document } from "./document";
|
|
||||||
import type { DocumentId } from "./ids";
|
|
||||||
|
|
||||||
function makeDoc(partial: Partial<Document>): Document {
|
|
||||||
return {
|
|
||||||
id: "doc_test" as DocumentId,
|
|
||||||
mediaType: "application/pdf",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
...partial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("resolveSourceLabel()", () => {
|
|
||||||
it("prefers document.title when set", () => {
|
|
||||||
expect(
|
|
||||||
resolveSourceLabel(
|
|
||||||
makeDoc({
|
|
||||||
title: "Order from 2024",
|
|
||||||
uri: "https://example.test/o.pdf",
|
|
||||||
metadata: { filename: "fallback.pdf" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toBe("Order from 2024");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to metadata.filename when title is missing", () => {
|
|
||||||
expect(
|
|
||||||
resolveSourceLabel(
|
|
||||||
makeDoc({
|
|
||||||
uri: "https://example.test/o.pdf",
|
|
||||||
metadata: { filename: "fallback.pdf" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toBe("fallback.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to uri when title and filename are missing", () => {
|
|
||||||
expect(
|
|
||||||
resolveSourceLabel(makeDoc({ uri: "https://example.test/o.pdf" })),
|
|
||||||
).toBe("https://example.test/o.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to id when everything else is missing", () => {
|
|
||||||
expect(resolveSourceLabel(makeDoc({}))).toBe("doc_test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats whitespace-only strings as empty", () => {
|
|
||||||
expect(
|
|
||||||
resolveSourceLabel(
|
|
||||||
makeDoc({ title: " ", uri: "https://example.test/o.pdf" }),
|
|
||||||
),
|
|
||||||
).toBe("https://example.test/o.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-string metadata.filename", () => {
|
|
||||||
expect(
|
|
||||||
resolveSourceLabel(
|
|
||||||
makeDoc({
|
|
||||||
metadata: { filename: 42 },
|
|
||||||
uri: "https://example.test/o.pdf",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toBe("https://example.test/o.pdf");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Source-label resolution for citation cards.
|
|
||||||
*
|
|
||||||
* Per workplan CE-WP-0004-T02: the label rendered after the quote is
|
|
||||||
* document.title → document.metadata.filename → document.uri → document.id
|
|
||||||
* — the first non-empty value wins. Renderers must call this helper so
|
|
||||||
* the two output formats (Markdown, HTML) stay consistent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Document } from "./document";
|
|
||||||
|
|
||||||
function nonEmptyString(value: unknown): string | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSourceLabel(document: Document): string {
|
|
||||||
const title = nonEmptyString(document.title);
|
|
||||||
if (title) return title;
|
|
||||||
const filename = nonEmptyString(document.metadata?.["filename"]);
|
|
||||||
if (filename) return filename;
|
|
||||||
const uri = nonEmptyString(document.uri);
|
|
||||||
if (uri) return uri;
|
|
||||||
return document.id;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* CitationCard — a presentable rendering of an EvidenceItem.
|
|
||||||
*
|
|
||||||
* Implements `wiki/ArchitectureOverview.md` §4.7. Stored or transient,
|
|
||||||
* always derived from an `EvidenceItem` + its `Document` + the first
|
|
||||||
* referenced `Annotation`. The renderers in `engine/rendering/` produce
|
|
||||||
* the string forms (`format: "markdown" | "html"`); the
|
|
||||||
* `"web-component"` format is reserved for a later workplan (see §14.2).
|
|
||||||
*
|
|
||||||
* `id` and `evidenceItemId` use the branded ID types from `./ids` so they
|
|
||||||
* cannot be swapped at compile time, even though `ArchitectureOverview.md`
|
|
||||||
* §4.7 shows them as bare `string`s. The brand is invisible at runtime.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CitationCardId, EvidenceItemId } from "./ids";
|
|
||||||
|
|
||||||
export type CitationCardFormat = "html" | "markdown" | "web-component";
|
|
||||||
|
|
||||||
export interface CitationCard {
|
|
||||||
readonly id: CitationCardId;
|
|
||||||
readonly evidenceItemId: EvidenceItemId;
|
|
||||||
/** Verbatim quote rendered into the card body. */
|
|
||||||
readonly quote: string;
|
|
||||||
/**
|
|
||||||
* Human-readable source label. Resolution order is
|
|
||||||
* `document.title` → `filename from metadata` → `document.uri` →
|
|
||||||
* the document id. Renderers should call `resolveSourceLabel()` from
|
|
||||||
* `./citation-card-source.ts` rather than computing this inline.
|
|
||||||
*/
|
|
||||||
readonly sourceLabel: string;
|
|
||||||
readonly commentary?: string;
|
|
||||||
/** Deep link to reopen the source context — see `./open-context-url.ts`. */
|
|
||||||
readonly openContextUrl?: string;
|
|
||||||
readonly format: CitationCardFormat;
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* Document and DocumentRepresentation types.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
|
|
||||||
* `wiki/ArchitectureOverview.md` §4.1, §4.2. Pure data — no behavior.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DocumentId, RepresentationId } from "./ids";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind of normalized view derived from a source document.
|
|
||||||
*
|
|
||||||
* MVP recognises only `pdf-text`; the other variants are reserved for the
|
|
||||||
* HTML/Markdown/OCR adapters that arrive after CE-WP-0002.
|
|
||||||
*/
|
|
||||||
export type RepresentationType =
|
|
||||||
| "pdf-text"
|
|
||||||
| "html-dom"
|
|
||||||
| "markdown-rendered"
|
|
||||||
| "plain-text"
|
|
||||||
| "ocr-text";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page-level geometry. One entry per physical PDF page.
|
|
||||||
* Coordinates are PDF user-space points (1/72 inch).
|
|
||||||
*/
|
|
||||||
export interface PageInfo {
|
|
||||||
/** 1-indexed physical page number. */
|
|
||||||
readonly page: number;
|
|
||||||
/** Page width in user-space points. */
|
|
||||||
readonly width: number;
|
|
||||||
/** Page height in user-space points. */
|
|
||||||
readonly height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PageMap = readonly PageInfo[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps canonical-text offset ranges to physical pages.
|
|
||||||
*
|
|
||||||
* Entries are sorted by `globalStart`, are non-overlapping, and together
|
|
||||||
* cover `[0, canonicalText.length)`. `pageLength` equals
|
|
||||||
* `globalEnd - globalStart` and is also the length of the page-local text
|
|
||||||
* (used by `PdfPageTextSelector`).
|
|
||||||
*/
|
|
||||||
export interface PageOffsetRange {
|
|
||||||
readonly page: number;
|
|
||||||
/** Inclusive canonical-text offset where this page begins. */
|
|
||||||
readonly globalStart: number;
|
|
||||||
/** Exclusive canonical-text offset where this page ends. */
|
|
||||||
readonly globalEnd: number;
|
|
||||||
/** Length of the page's text in canonical-text characters. */
|
|
||||||
readonly pageLength: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OffsetMap = readonly PageOffsetRange[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reserved for `StructuralSelector` (heading/section/AST path).
|
|
||||||
* Not implementable in MVP — type is `never` to enforce that at compile time.
|
|
||||||
*/
|
|
||||||
export type StructureMap = never;
|
|
||||||
|
|
||||||
/** A source document known to the system. */
|
|
||||||
export interface Document {
|
|
||||||
readonly id: DocumentId;
|
|
||||||
readonly title?: string;
|
|
||||||
readonly uri?: string;
|
|
||||||
readonly mediaType: string;
|
|
||||||
readonly fingerprint?: string;
|
|
||||||
readonly version?: string;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A normalized, addressable view of a `Document`. */
|
|
||||||
export interface DocumentRepresentation {
|
|
||||||
readonly id: RepresentationId;
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly representationType: RepresentationType;
|
|
||||||
/** Hash of the canonical text — stable identifier for the representation. */
|
|
||||||
readonly contentHash: string;
|
|
||||||
/** Canonical text after `normalize()` is applied. */
|
|
||||||
readonly canonicalText?: string;
|
|
||||||
readonly pageMap?: PageMap;
|
|
||||||
readonly structureMap?: StructureMap;
|
|
||||||
readonly offsetMap?: OffsetMap;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly generatedAt: string;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Conformance test: the runtime enum lists in `evidence-link.ts` must
|
|
||||||
* match the lists in `wiki/SharedContracts.md` §2.4 and §2.5 exactly.
|
|
||||||
*
|
|
||||||
* If you intentionally change an enum, update both the doc and the
|
|
||||||
* runtime list together — this test will tell you which one you forgot.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
EVIDENCE_LINK_STATUS_VALUES,
|
|
||||||
EVIDENCE_RELATION_VALUES,
|
|
||||||
} from "./evidence-link";
|
|
||||||
|
|
||||||
const HERE = fileURLToPath(new URL(".", import.meta.url));
|
|
||||||
const CONTRACTS_PATH = resolve(HERE, "../../wiki/SharedContracts.md");
|
|
||||||
|
|
||||||
function extractFencedListAfterHeading(markdown: string, heading: string): string[] {
|
|
||||||
const headingIndex = markdown.indexOf(heading);
|
|
||||||
if (headingIndex === -1) {
|
|
||||||
throw new Error(`Could not find heading "${heading}" in SharedContracts.md`);
|
|
||||||
}
|
|
||||||
const after = markdown.slice(headingIndex + heading.length);
|
|
||||||
const fenceOpen = after.indexOf("```");
|
|
||||||
if (fenceOpen === -1) throw new Error(`No fenced block after "${heading}"`);
|
|
||||||
const bodyStart = after.indexOf("\n", fenceOpen) + 1;
|
|
||||||
const fenceClose = after.indexOf("```", bodyStart);
|
|
||||||
if (fenceClose === -1) throw new Error(`Unterminated fenced block after "${heading}"`);
|
|
||||||
const body = after.slice(bodyStart, fenceClose);
|
|
||||||
return body
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
// Strip trailing " — explanatory note" if present (none in §2.4/§2.5 today,
|
|
||||||
// but §2.1/§2.2 use that style — being defensive keeps the helper reusable).
|
|
||||||
.map((line) => line.split(/\s+[—-]\s+/)[0]!.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("EvidenceLink enum conformance with SharedContracts.md", () => {
|
|
||||||
const markdown = readFileSync(CONTRACTS_PATH, "utf8");
|
|
||||||
|
|
||||||
it("§2.4 EvidenceLink.status matches EVIDENCE_LINK_STATUS_VALUES", () => {
|
|
||||||
const docValues = extractFencedListAfterHeading(
|
|
||||||
markdown,
|
|
||||||
"### 2.4 `EvidenceLink.status` (per target)",
|
|
||||||
);
|
|
||||||
expect(docValues).toEqual([...EVIDENCE_LINK_STATUS_VALUES]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("§2.5 EvidenceLink.relation matches EVIDENCE_RELATION_VALUES", () => {
|
|
||||||
const docValues = extractFencedListAfterHeading(
|
|
||||||
markdown,
|
|
||||||
"### 2.5 `EvidenceLink.relation`",
|
|
||||||
);
|
|
||||||
expect(docValues).toEqual([...EVIDENCE_RELATION_VALUES]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* EvidenceLink + EvidenceTarget shapes.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.4
|
|
||||||
* (EvidenceLink.status) and §2.5 (EvidenceLink.relation), and
|
|
||||||
* `wiki/ArchitectureOverview.md` §4 (target-type catalogue).
|
|
||||||
*
|
|
||||||
* An EvidenceLink ties exactly one EvidenceItem to one structured target
|
|
||||||
* (e.g. a form field). Multiple links per item are allowed when the same
|
|
||||||
* evidence supports several targets. Multiple links per target are allowed
|
|
||||||
* when several pieces of evidence apply to the same field — the
|
|
||||||
* EvidenceSet captures that ordered group.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EvidenceItemId, EvidenceLinkId } from "./ids";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closed enum per `wiki/SharedContracts.md` §2.4.
|
|
||||||
*
|
|
||||||
* `no-evidence` is a *derived* state — computed when a target has zero
|
|
||||||
* links — and is therefore NOT stored on a link itself. The stored values
|
|
||||||
* are the five members of `EvidenceLinkStoredStatus`.
|
|
||||||
*/
|
|
||||||
export type EvidenceLinkStatus =
|
|
||||||
| "no-evidence"
|
|
||||||
| "candidate"
|
|
||||||
| "confirmed"
|
|
||||||
| "conflicting"
|
|
||||||
| "insufficient"
|
|
||||||
| "verified";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subset of `EvidenceLinkStatus` that may appear on a stored link
|
|
||||||
* record. `no-evidence` is excluded because it is derived from the
|
|
||||||
* absence of links on a target, not stored.
|
|
||||||
*/
|
|
||||||
export type EvidenceLinkStoredStatus = Exclude<EvidenceLinkStatus, "no-evidence">;
|
|
||||||
|
|
||||||
/** Closed enum per `wiki/SharedContracts.md` §2.5. */
|
|
||||||
export type EvidenceRelation =
|
|
||||||
| "supports"
|
|
||||||
| "contradicts"
|
|
||||||
| "explains"
|
|
||||||
| "qualifies"
|
|
||||||
| "source-for"
|
|
||||||
| "context-for";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Known target-type catalogue per `wiki/ArchitectureOverview.md` §4
|
|
||||||
* (`EvidenceTargetType`). The MVP only exercises `"form-field"`; the
|
|
||||||
* others are reserved so future workplans can extend without renaming.
|
|
||||||
*/
|
|
||||||
export type EvidenceTargetType =
|
|
||||||
| "form-field"
|
|
||||||
| "claim"
|
|
||||||
| "requirement"
|
|
||||||
| "decision"
|
|
||||||
| "document-section";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic shape of an evidence target. `targetId` is opaque to the engine
|
|
||||||
* — the host subsystem (form renderer, claims index, …) owns the
|
|
||||||
* namespace for its `targetType`.
|
|
||||||
*/
|
|
||||||
export interface EvidenceTarget {
|
|
||||||
readonly targetType: EvidenceTargetType;
|
|
||||||
readonly targetId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvidenceLink {
|
|
||||||
readonly id: EvidenceLinkId;
|
|
||||||
readonly evidenceItemId: EvidenceItemId;
|
|
||||||
readonly targetType: EvidenceTargetType;
|
|
||||||
readonly targetId: string;
|
|
||||||
readonly relation: EvidenceRelation;
|
|
||||||
readonly status: EvidenceLinkStoredStatus;
|
|
||||||
/** Optional 0..1 confidence assigned by user or auto-process. */
|
|
||||||
readonly confidence?: number;
|
|
||||||
readonly createdBy?: string;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly createdAt: string;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The canonical lists, exported for use by enum-conformance tests
|
|
||||||
* (see `evidence-link.test.ts`) and for any UI code that needs to
|
|
||||||
* enumerate options. Order matches `wiki/SharedContracts.md`.
|
|
||||||
*/
|
|
||||||
export const EVIDENCE_LINK_STATUS_VALUES: readonly EvidenceLinkStatus[] = [
|
|
||||||
"no-evidence",
|
|
||||||
"candidate",
|
|
||||||
"confirmed",
|
|
||||||
"conflicting",
|
|
||||||
"insufficient",
|
|
||||||
"verified",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const EVIDENCE_RELATION_VALUES: readonly EvidenceRelation[] = [
|
|
||||||
"supports",
|
|
||||||
"contradicts",
|
|
||||||
"explains",
|
|
||||||
"qualifies",
|
|
||||||
"source-for",
|
|
||||||
"context-for",
|
|
||||||
];
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* EvidenceSet — an ordered group of evidence items pointed at a target.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
|
|
||||||
* `wiki/ArchitectureOverview.md` §4.6.
|
|
||||||
*
|
|
||||||
* The set itself is target-shaped: it carries the `(targetType, targetId)`
|
|
||||||
* pair so the binder can answer "give me the EvidenceSet for this form
|
|
||||||
* field" in one call. `activeEvidenceItemId` is the membership of the
|
|
||||||
* set that the UI is currently focused on; cycling Tab/Shift-Tab through
|
|
||||||
* the field's chips updates it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EvidenceItemId, EvidenceSetId } from "./ids";
|
|
||||||
import type { EvidenceTargetType } from "./evidence-link";
|
|
||||||
|
|
||||||
export interface EvidenceSet {
|
|
||||||
readonly id: EvidenceSetId;
|
|
||||||
readonly label?: string;
|
|
||||||
/**
|
|
||||||
* Optional target binding. Form-field sets always carry these; ad-hoc
|
|
||||||
* topical sets may leave them undefined.
|
|
||||||
*/
|
|
||||||
readonly targetType?: EvidenceTargetType;
|
|
||||||
readonly targetId?: string;
|
|
||||||
/**
|
|
||||||
* Membership in display order. The binder is free to reorder, but
|
|
||||||
* persistence preserves this order so cycling is deterministic.
|
|
||||||
*/
|
|
||||||
readonly evidenceItemIds: readonly EvidenceItemId[];
|
|
||||||
/**
|
|
||||||
* The currently active member, or undefined if the set is empty or
|
|
||||||
* no member is yet focused.
|
|
||||||
*/
|
|
||||||
readonly activeEvidenceItemId?: EvidenceItemId;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* EvidenceItem type.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.2 (status enum)
|
|
||||||
* and `wiki/ArchitectureOverview.md` §4.5. An EvidenceItem is the *meaning*
|
|
||||||
* layer on top of one or more technical Annotations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AnnotationId, EvidenceItemId } from "./ids";
|
|
||||||
|
|
||||||
/** Closed enum per `wiki/SharedContracts.md` §2.2. */
|
|
||||||
export type EvidenceItemStatus =
|
|
||||||
| "candidate"
|
|
||||||
| "confirmed"
|
|
||||||
| "rejected"
|
|
||||||
| "needs-check";
|
|
||||||
|
|
||||||
export interface EvidenceItem {
|
|
||||||
readonly id: EvidenceItemId;
|
|
||||||
/**
|
|
||||||
* One or more annotations that together constitute the evidence.
|
|
||||||
* Multiple annotations are used when a piece of evidence spans
|
|
||||||
* discontiguous passages.
|
|
||||||
*/
|
|
||||||
readonly annotationIds: readonly AnnotationId[];
|
|
||||||
readonly title?: string;
|
|
||||||
readonly commentary?: string;
|
|
||||||
readonly status: EvidenceItemStatus;
|
|
||||||
/** Optional 0..1 confidence assigned by user or auto-process. */
|
|
||||||
readonly confidence?: number;
|
|
||||||
readonly tags?: readonly string[];
|
|
||||||
readonly createdBy?: string;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly createdAt: string;
|
|
||||||
/** ISO-8601 timestamp. */
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { newId } from "./ids";
|
|
||||||
|
|
||||||
describe("newId", () => {
|
|
||||||
it("returns ids with the expected prefix for each kind", () => {
|
|
||||||
expect(newId("document")).toMatch(/^doc_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("representation")).toMatch(/^rep_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("annotation")).toMatch(/^ann_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("evidence")).toMatch(/^ev_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("evidence-set")).toMatch(/^evset_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("evidence-link")).toMatch(/^evlink_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("citation-card")).toMatch(/^card_[0-9a-f-]{36}$/);
|
|
||||||
expect(newId("citation-recovery")).toMatch(/^crec_[0-9a-f-]{36}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a unique id on every call", () => {
|
|
||||||
const a = newId("annotation");
|
|
||||||
const b = newId("annotation");
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Branded ID types and the `newId(kind)` factory.
|
|
||||||
*
|
|
||||||
* Implements the identifier portion of `wiki/SharedContracts.md` §1 and
|
|
||||||
* `wiki/ArchitectureOverview.md` §3.2. Each branded type is structurally a
|
|
||||||
* `string` but nominally distinct, so passing an `AnnotationId` where a
|
|
||||||
* `DocumentId` is required is a compile-time error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare const __brand: unique symbol;
|
|
||||||
|
|
||||||
type Brand<K, T extends string> = K & { readonly [__brand]: T };
|
|
||||||
|
|
||||||
export type DocumentId = Brand<string, "DocumentId">;
|
|
||||||
export type RepresentationId = Brand<string, "RepresentationId">;
|
|
||||||
export type AnnotationId = Brand<string, "AnnotationId">;
|
|
||||||
export type EvidenceItemId = Brand<string, "EvidenceItemId">;
|
|
||||||
export type EvidenceSetId = Brand<string, "EvidenceSetId">;
|
|
||||||
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
|
|
||||||
export type CitationCardId = Brand<string, "CitationCardId">;
|
|
||||||
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
|
|
||||||
export type SessionId = Brand<string, "SessionId">;
|
|
||||||
|
|
||||||
export type IdKindMap = {
|
|
||||||
document: DocumentId;
|
|
||||||
representation: RepresentationId;
|
|
||||||
annotation: AnnotationId;
|
|
||||||
evidence: EvidenceItemId;
|
|
||||||
"evidence-set": EvidenceSetId;
|
|
||||||
"evidence-link": EvidenceLinkId;
|
|
||||||
"citation-card": CitationCardId;
|
|
||||||
"citation-recovery": CitationRecoveryAttemptId;
|
|
||||||
session: SessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IdKind = keyof IdKindMap;
|
|
||||||
|
|
||||||
const PREFIXES: Record<IdKind, string> = {
|
|
||||||
document: "doc",
|
|
||||||
representation: "rep",
|
|
||||||
annotation: "ann",
|
|
||||||
evidence: "ev",
|
|
||||||
"evidence-set": "evset",
|
|
||||||
"evidence-link": "evlink",
|
|
||||||
"citation-card": "card",
|
|
||||||
"citation-recovery": "crec",
|
|
||||||
session: "sess",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mint a new branded identifier of the requested kind.
|
|
||||||
*
|
|
||||||
* IDs use the shape `<prefix>_<uuid>` so they are human-recognizable when
|
|
||||||
* they show up in logs, URLs, or stored JSON.
|
|
||||||
*/
|
|
||||||
export function newId<K extends IdKind>(kind: K): IdKindMap[K] {
|
|
||||||
return `${PREFIXES[kind]}_${crypto.randomUUID()}` as IdKindMap[K];
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export * from "./ids";
|
|
||||||
export * from "./document";
|
|
||||||
export * from "./selector";
|
|
||||||
export * from "./annotation";
|
|
||||||
export * from "./evidence";
|
|
||||||
export * from "./evidence-link";
|
|
||||||
export * from "./evidence-set";
|
|
||||||
export * from "./citation-card";
|
|
||||||
export * from "./citation-card-source";
|
|
||||||
export * from "./open-context-url";
|
|
||||||
export * from "./session";
|
|
||||||
export * from "./session-archive";
|
|
||||||
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for `openContextUrl()`. Locks the shape from
|
|
||||||
* `wiki/ArchitectureOverview.md` §14.3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { AnnotationId, DocumentId } from "./ids";
|
|
||||||
import { openContextUrl } from "./open-context-url";
|
|
||||||
|
|
||||||
const DOC = "doc_abc-123" as DocumentId;
|
|
||||||
const ANN = "ann_def-456" as AnnotationId;
|
|
||||||
|
|
||||||
describe("openContextUrl()", () => {
|
|
||||||
it("produces the canonical /viewer?document=…&annotation=… shape", () => {
|
|
||||||
expect(openContextUrl({ documentId: DOC, annotationId: ANN })).toBe(
|
|
||||||
"/viewer?document=doc_abc-123&annotation=ann_def-456",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("percent-encodes reserved characters in ids", () => {
|
|
||||||
const ugly = "doc with spaces&=#" as DocumentId;
|
|
||||||
const url = openContextUrl({ documentId: ugly, annotationId: ANN });
|
|
||||||
expect(url).toBe(
|
|
||||||
"/viewer?document=doc%20with%20spaces%26%3D%23&annotation=ann_def-456",
|
|
||||||
);
|
|
||||||
// round-trip
|
|
||||||
const params = new URL(url, "https://example.test").searchParams;
|
|
||||||
expect(params.get("document")).toBe("doc with spaces&=#");
|
|
||||||
expect(params.get("annotation")).toBe("ann_def-456");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Open-context URL — the canonical deep link that lets a citation card
|
|
||||||
* reopen the source context that backs it.
|
|
||||||
*
|
|
||||||
* Implements `wiki/ArchitectureOverview.md` §14.3. The URL shape is:
|
|
||||||
*
|
|
||||||
* /viewer?document=<documentId>&annotation=<annotationId>
|
|
||||||
*
|
|
||||||
* Both ids are mandatory. The annotation alone is insufficient because
|
|
||||||
* the router needs the document id to mount the right viewer adapter
|
|
||||||
* before resolving the annotation's selectors.
|
|
||||||
*
|
|
||||||
* This convention is stable across persistence modes — when the
|
|
||||||
* in-memory engine is replaced by a real backend (ADR-0005), the same
|
|
||||||
* URL shape is expected to still resolve.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AnnotationId, DocumentId } from "./ids";
|
|
||||||
|
|
||||||
export interface OpenContextUrlInput {
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly annotationId: AnnotationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the deep link for a given (document, annotation) pair.
|
|
||||||
*
|
|
||||||
* Query-string values are percent-encoded via `encodeURIComponent` so that
|
|
||||||
* any future id scheme containing reserved characters (`&`, `=`, `#`) still
|
|
||||||
* round-trips.
|
|
||||||
*/
|
|
||||||
export function openContextUrl(input: OpenContextUrlInput): string {
|
|
||||||
const docPart = encodeURIComponent(input.documentId);
|
|
||||||
const annPart = encodeURIComponent(input.annotationId);
|
|
||||||
return `/viewer?document=${docPart}&annotation=${annPart}`;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* The Selector discriminated union.
|
|
||||||
*
|
|
||||||
* Implements `wiki/SharedContracts.md` §3. Each selector kind has a unique
|
|
||||||
* `type` discriminator and locates a passage inside one
|
|
||||||
* `DocumentRepresentation`.
|
|
||||||
*
|
|
||||||
* The MVP implements the four PDF-relevant variants
|
|
||||||
* (`TextQuoteSelector`, `TextPositionSelector`, `PdfRectSelector`,
|
|
||||||
* `PdfPageTextSelector`). The other three kinds (DOM, structural, fragment)
|
|
||||||
* are reserved as `never`-typed stubs so adding them later is a localised
|
|
||||||
* change.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Exact quote with optional surrounding context (W3C-aligned). */
|
|
||||||
export interface TextQuoteSelector {
|
|
||||||
readonly type: "TextQuoteSelector";
|
|
||||||
/** The verbatim quoted passage from the canonical text. */
|
|
||||||
readonly exact: string;
|
|
||||||
/** Up to ~32 chars of canonical text immediately before `exact`. */
|
|
||||||
readonly prefix?: string;
|
|
||||||
/** Up to ~32 chars of canonical text immediately after `exact`. */
|
|
||||||
readonly suffix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Canonical-text character offsets (inclusive start, exclusive end). */
|
|
||||||
export interface TextPositionSelector {
|
|
||||||
readonly type: "TextPositionSelector";
|
|
||||||
readonly start: number;
|
|
||||||
readonly end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A rectangle on a PDF page, in page-relative normalized coordinates (0..1). */
|
|
||||||
export interface NormalizedRect {
|
|
||||||
readonly x: number;
|
|
||||||
readonly y: number;
|
|
||||||
readonly width: number;
|
|
||||||
readonly height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One or more rectangles on a single PDF page. */
|
|
||||||
export interface PdfRectSelector {
|
|
||||||
readonly type: "PdfRectSelector";
|
|
||||||
/** 1-indexed physical page number. */
|
|
||||||
readonly page: number;
|
|
||||||
readonly rects: readonly NormalizedRect[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Page-local text offsets, for a single PDF page. */
|
|
||||||
export interface PdfPageTextSelector {
|
|
||||||
readonly type: "PdfPageTextSelector";
|
|
||||||
readonly page: number;
|
|
||||||
readonly start: number;
|
|
||||||
readonly end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reserved for HTML/Markdown viewer adapters. Not implementable in MVP. */
|
|
||||||
export type DomRangeSelector = never;
|
|
||||||
|
|
||||||
/** Reserved for heading/section/AST-path locators. Not implementable in MVP. */
|
|
||||||
export type StructuralSelector = never;
|
|
||||||
|
|
||||||
/** Reserved for exported deep-link fragments. Not implementable in MVP. */
|
|
||||||
export type FragmentSelector = never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The closed union of all selector kinds. The `never` members keep the union
|
|
||||||
* exhaustive so future selector additions are a single edit.
|
|
||||||
*/
|
|
||||||
export type Selector =
|
|
||||||
| TextQuoteSelector
|
|
||||||
| TextPositionSelector
|
|
||||||
| PdfRectSelector
|
|
||||||
| PdfPageTextSelector
|
|
||||||
| DomRangeSelector
|
|
||||||
| StructuralSelector
|
|
||||||
| FragmentSelector;
|
|
||||||
|
|
||||||
export type SelectorType = Selector["type"];
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { DocumentId, SessionId } from "./ids";
|
|
||||||
import {
|
|
||||||
parseSessionArchiveManifest,
|
|
||||||
SESSION_ARCHIVE_SCHEMA_VERSION,
|
|
||||||
SessionArchiveParseError,
|
|
||||||
type SessionArchiveManifest,
|
|
||||||
} from "./session-archive";
|
|
||||||
|
|
||||||
function validManifest(): SessionArchiveManifest {
|
|
||||||
return {
|
|
||||||
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
|
||||||
exportedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
session: {
|
|
||||||
id: "sess_abc" as SessionId,
|
|
||||||
name: "Demo",
|
|
||||||
createdAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
engine: {
|
|
||||||
version: 1,
|
|
||||||
documents: [],
|
|
||||||
representations: [],
|
|
||||||
annotations: [],
|
|
||||||
evidenceItems: [],
|
|
||||||
},
|
|
||||||
documentBindings: [
|
|
||||||
{
|
|
||||||
documentId: "doc_abc" as DocumentId,
|
|
||||||
filename: "demo.pdf",
|
|
||||||
fingerprint: "abc123",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("parseSessionArchiveManifest", () => {
|
|
||||||
it("round-trips a valid manifest through JSON.stringify + parse", () => {
|
|
||||||
const m = validManifest();
|
|
||||||
const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m)));
|
|
||||||
expect(round).toEqual(m);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unsupported schemaVersion", () => {
|
|
||||||
const m = { ...validManifest(), schemaVersion: 999 as unknown };
|
|
||||||
expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError);
|
|
||||||
expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a missing required top-level field", () => {
|
|
||||||
const m = validManifest();
|
|
||||||
const broken = { ...m, exportedAt: undefined as unknown };
|
|
||||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a malformed session sub-object", () => {
|
|
||||||
const m = validManifest();
|
|
||||||
const broken = { ...m, session: { ...m.session, id: 12345 as unknown } };
|
|
||||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a malformed engine snapshot", () => {
|
|
||||||
const m = validManifest();
|
|
||||||
const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } };
|
|
||||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a non-array documentBindings", () => {
|
|
||||||
const m = validManifest();
|
|
||||||
const broken = { ...m, documentBindings: "nope" as unknown };
|
|
||||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a malformed documentBindings entry", () => {
|
|
||||||
const m = validManifest();
|
|
||||||
const broken = {
|
|
||||||
...m,
|
|
||||||
documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[],
|
|
||||||
};
|
|
||||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a non-object root", () => {
|
|
||||||
expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/);
|
|
||||||
expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* SessionArchiveManifest — JSON contract for `manifest.json` inside a
|
|
||||||
* session ZIP archive.
|
|
||||||
*
|
|
||||||
* Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md`
|
|
||||||
* for the authoritative spec. This module is the executable contract:
|
|
||||||
* `parseSessionArchiveManifest` validates an `unknown` JSON value and
|
|
||||||
* narrows it to `SessionArchiveManifest` or throws with a useful
|
|
||||||
* message.
|
|
||||||
*
|
|
||||||
* The `engine` field re-uses the in-memory `EngineSnapshot` shape so
|
|
||||||
* the in-memory ↔ archive round-trip stays a one-way conversion.
|
|
||||||
* Only minimal structural validation runs here; the engine helpers
|
|
||||||
* (`restoreSnapshot`) handle deeper validation when actually
|
|
||||||
* hydrating an engine.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DocumentId, SessionId } from "./ids";
|
|
||||||
|
|
||||||
export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const;
|
|
||||||
|
|
||||||
export interface SessionArchiveSessionRecord {
|
|
||||||
readonly id: SessionId;
|
|
||||||
readonly name: string;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionArchiveDocumentBinding {
|
|
||||||
readonly documentId: DocumentId;
|
|
||||||
readonly filename: string;
|
|
||||||
readonly fingerprint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirror of `EngineSnapshot` — kept loose here (record of unknown) to
|
|
||||||
* avoid the cross-package dependency back into `@engine`. The engine
|
|
||||||
* persistence layer owns the authoritative shape.
|
|
||||||
*/
|
|
||||||
export interface SessionArchiveEngineSnapshot {
|
|
||||||
readonly version: number;
|
|
||||||
readonly documents: readonly unknown[];
|
|
||||||
readonly representations: readonly unknown[];
|
|
||||||
readonly annotations: readonly unknown[];
|
|
||||||
readonly evidenceItems: readonly unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionArchiveManifest {
|
|
||||||
readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION;
|
|
||||||
readonly exportedAt: string;
|
|
||||||
readonly session: SessionArchiveSessionRecord;
|
|
||||||
readonly engine: SessionArchiveEngineSnapshot;
|
|
||||||
readonly documentBindings: readonly SessionArchiveDocumentBinding[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SessionArchiveParseError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(`SessionArchiveManifest parse failed: ${message}`);
|
|
||||||
this.name = "SessionArchiveParseError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertString(value: unknown, field: string): string {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new SessionArchiveParseError(`field "${field}" must be a string`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertObject(value: unknown, field: string): Record<string, unknown> {
|
|
||||||
if (!isObject(value)) {
|
|
||||||
throw new SessionArchiveParseError(`field "${field}" must be an object`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertArray(value: unknown, field: string): readonly unknown[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
throw new SessionArchiveParseError(`field "${field}" must be an array`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord {
|
|
||||||
const obj = assertObject(raw, "session");
|
|
||||||
return {
|
|
||||||
id: assertString(obj.id, "session.id") as SessionId,
|
|
||||||
name: assertString(obj.name, "session.name"),
|
|
||||||
createdAt: assertString(obj.createdAt, "session.createdAt"),
|
|
||||||
updatedAt: assertString(obj.updatedAt, "session.updatedAt"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDocumentBinding(
|
|
||||||
raw: unknown,
|
|
||||||
index: number,
|
|
||||||
): SessionArchiveDocumentBinding {
|
|
||||||
const obj = assertObject(raw, `documentBindings[${index}]`);
|
|
||||||
return {
|
|
||||||
documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId,
|
|
||||||
filename: assertString(obj.filename, `documentBindings[${index}].filename`),
|
|
||||||
fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot {
|
|
||||||
const obj = assertObject(raw, "engine");
|
|
||||||
const version = obj.version;
|
|
||||||
if (typeof version !== "number") {
|
|
||||||
throw new SessionArchiveParseError(`field "engine.version" must be a number`);
|
|
||||||
}
|
|
||||||
const documents = assertArray(obj.documents, "engine.documents");
|
|
||||||
const representations = assertArray(obj.representations, "engine.representations");
|
|
||||||
const annotations = assertArray(obj.annotations, "engine.annotations");
|
|
||||||
const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems");
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
documents,
|
|
||||||
representations,
|
|
||||||
annotations,
|
|
||||||
evidenceItems,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest {
|
|
||||||
const obj = assertObject(raw, "manifest");
|
|
||||||
const schemaVersion = obj.schemaVersion;
|
|
||||||
if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) {
|
|
||||||
throw new SessionArchiveParseError(
|
|
||||||
`unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const exportedAt = assertString(obj.exportedAt, "exportedAt");
|
|
||||||
const session = parseSessionRecord(obj.session);
|
|
||||||
const engine = parseEngineSnapshot(obj.engine);
|
|
||||||
const documentBindings = assertArray(obj.documentBindings, "documentBindings").map(
|
|
||||||
(entry, i) => parseDocumentBinding(entry, i),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
|
||||||
exportedAt,
|
|
||||||
session,
|
|
||||||
engine,
|
|
||||||
documentBindings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* `Session` — a user-named workspace that owns one engine snapshot.
|
|
||||||
*
|
|
||||||
* Sessions partition the demo app: each one holds its own documents,
|
|
||||||
* annotations, evidence items, and links. Membership is implicit — a
|
|
||||||
* session "owns" whatever lives in its engine snapshot. The session
|
|
||||||
* record itself only carries the human metadata (name, timestamps) and
|
|
||||||
* the branded id used as a key in `localStorage` and the ZIP archive
|
|
||||||
* (see ADR-0008).
|
|
||||||
*
|
|
||||||
* The id is opaque (`sess_<uuid>` per `ids.ts`). The name is the human
|
|
||||||
* label; uniqueness is enforced by the `SessionService` on create and
|
|
||||||
* rename. Names are *trimmed* before storage so a leading/trailing
|
|
||||||
* space does not let two sessions coexist with effectively the same
|
|
||||||
* label.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SessionId } from "./ids";
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
readonly id: SessionId;
|
|
||||||
readonly name: string;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
readonly lastOpenedAt?: string;
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -16,3 +16,8 @@ export {
|
|||||||
ingestPdfFromFile,
|
ingestPdfFromFile,
|
||||||
type IngestPdfFromFileOptions,
|
type IngestPdfFromFileOptions,
|
||||||
} from "./pdf/upload";
|
} from "./pdf/upload";
|
||||||
|
export {
|
||||||
|
isEphemeralBlobUri,
|
||||||
|
resolvePdfViewerUrl,
|
||||||
|
documentHasUploadedBytes,
|
||||||
|
} from "./pdf/viewer-url";
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ beforeAll(async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ingestPdf — fixture corpus", () => {
|
describe("ingestPdf — fixture corpus", { timeout: 30_000 }, () => {
|
||||||
for (const fixture of FIXTURES) {
|
for (const fixture of FIXTURES) {
|
||||||
describe(fixture.id, () => {
|
describe(fixture.id, () => {
|
||||||
const path = resolve(FIXTURE_DIR, fixture.filename);
|
const path = resolve(FIXTURE_DIR, fixture.filename);
|
||||||
|
|||||||
54
src/source/pdf/viewer-url.test.ts
Normal file
54
src/source/pdf/viewer-url.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Document } from "@shared/document";
|
||||||
|
import type { DocumentId } from "@shared/ids";
|
||||||
|
|
||||||
|
import { createPdfByteStore } from "./byte-store";
|
||||||
|
import { isEphemeralBlobUri, resolvePdfViewerUrl } from "./viewer-url";
|
||||||
|
|
||||||
|
function doc(overrides: Partial<Document> = {}): Document {
|
||||||
|
return {
|
||||||
|
id: "doc_1" as DocumentId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
title: "sample.pdf",
|
||||||
|
createdAt: "2026-06-07T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-07T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolvePdfViewerUrl", () => {
|
||||||
|
it("prefers live byte-store blob over persisted document.uri", () => {
|
||||||
|
const store = createPdfByteStore({
|
||||||
|
createObjectURL: () => "blob:live",
|
||||||
|
revokeObjectURL: () => {},
|
||||||
|
});
|
||||||
|
store.put("doc_1" as DocumentId, new Uint8Array([1]));
|
||||||
|
const url = resolvePdfViewerUrl(
|
||||||
|
doc({ uri: "blob:stale-from-localStorage" }),
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
expect(url).toBe("blob:live");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses non-blob document.uri when byte store is empty", () => {
|
||||||
|
const store = createPdfByteStore();
|
||||||
|
expect(resolvePdfViewerUrl(doc({ uri: "file:///x.pdf" }), store)).toBe(
|
||||||
|
"file:///x.pdf",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to fixture path when only a stale blob uri is stored", () => {
|
||||||
|
const store = createPdfByteStore();
|
||||||
|
expect(
|
||||||
|
resolvePdfViewerUrl(doc({ uri: "blob:revoked", title: "a.pdf" }), store),
|
||||||
|
).toBe("/fixtures/pdfs/a.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isEphemeralBlobUri", () => {
|
||||||
|
it("detects blob URLs", () => {
|
||||||
|
expect(isEphemeralBlobUri("blob:http://localhost/x")).toBe(true);
|
||||||
|
expect(isEphemeralBlobUri("/fixtures/pdfs/x.pdf")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/source/pdf/viewer-url.ts
Normal file
43
src/source/pdf/viewer-url.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the URL the PDF viewer should load for a document.
|
||||||
|
*
|
||||||
|
* Uploaded PDFs are served via ephemeral `blob:` URLs owned by
|
||||||
|
* `PdfByteStore`. Those URLs must not be persisted (see persistence
|
||||||
|
* sanitization) and must be re-resolved from live bytes on every render.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Document } from "@shared/document";
|
||||||
|
import type { DocumentId } from "@shared/ids";
|
||||||
|
|
||||||
|
import type { PdfByteStore } from "./byte-store";
|
||||||
|
|
||||||
|
export function isEphemeralBlobUri(uri: string | undefined): boolean {
|
||||||
|
return typeof uri === "string" && uri.startsWith("blob:");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer the byte store's live blob URL for uploaded documents; fall back
|
||||||
|
* to a stable HTTP fixture path or a non-blob `document.uri`.
|
||||||
|
*/
|
||||||
|
export function resolvePdfViewerUrl(
|
||||||
|
document: Document,
|
||||||
|
byteStore: PdfByteStore,
|
||||||
|
): string | null {
|
||||||
|
const live = byteStore.get(document.id);
|
||||||
|
if (live) return live.blobUrl;
|
||||||
|
|
||||||
|
if (document.uri && !isEphemeralBlobUri(document.uri)) {
|
||||||
|
return document.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleOrId = document.title ?? document.id;
|
||||||
|
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when bytes exist in the store but the document record lacks a URI. */
|
||||||
|
export function documentHasUploadedBytes(
|
||||||
|
documentId: DocumentId,
|
||||||
|
byteStore: PdfByteStore,
|
||||||
|
): boolean {
|
||||||
|
return byteStore.has(documentId);
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* AnnotationToolbar — wires "I selected text" into "evidence appears in
|
|
||||||
* the sidebar".
|
|
||||||
*
|
|
||||||
* Visible only when a `pendingSelection` is set (the viewer publishes
|
|
||||||
* captures into context, then this toolbar lets the user attach commentary
|
|
||||||
* and commit). On Save it runs the full pipeline:
|
|
||||||
*
|
|
||||||
* 1. `createSelectors(capture, representation)` — anchor builds the
|
|
||||||
* maximal selector set against the active representation.
|
|
||||||
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
|
|
||||||
* emits AnnotationCreated.
|
|
||||||
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem with
|
|
||||||
* the user's commentary, emits EvidenceItemCreated.
|
|
||||||
*
|
|
||||||
* The sidebar re-renders via the engine event bus, so no other glue is
|
|
||||||
* needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { createSelectors } from "@anchor/index";
|
|
||||||
import {
|
|
||||||
useActiveDocument,
|
|
||||||
useEngine,
|
|
||||||
usePendingSelection,
|
|
||||||
} from "./EngineContext";
|
|
||||||
|
|
||||||
export function AnnotationToolbar() {
|
|
||||||
const engine = useEngine();
|
|
||||||
const { document, representation } = useActiveDocument();
|
|
||||||
const { pending, set } = usePendingSelection();
|
|
||||||
const [commentary, setCommentary] = useState("");
|
|
||||||
|
|
||||||
// Reset the commentary box whenever a fresh selection arrives.
|
|
||||||
useEffect(() => {
|
|
||||||
setCommentary("");
|
|
||||||
}, [pending]);
|
|
||||||
|
|
||||||
if (!pending || !document || !representation) return null;
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const selectors = createSelectors(pending.capture, representation);
|
|
||||||
const annotation = engine.annotations.create({
|
|
||||||
documentId: document.id,
|
|
||||||
representationId: representation.id,
|
|
||||||
selectors,
|
|
||||||
quote: pending.capture.text,
|
|
||||||
});
|
|
||||||
engine.evidence.create({
|
|
||||||
annotationIds: [annotation.id],
|
|
||||||
...(commentary.trim().length > 0 ? { commentary: commentary.trim() } : {}),
|
|
||||||
});
|
|
||||||
set(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDiscard = () => set(null);
|
|
||||||
|
|
||||||
const quote = pending.capture.text;
|
|
||||||
const shortQuote = quote.length > 200 ? `${quote.slice(0, 200)}…` : quote;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderBottom: "1px solid #f0c040",
|
|
||||||
background: "#fff8d6",
|
|
||||||
padding: 8,
|
|
||||||
fontFamily: "system-ui, sans-serif",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>
|
|
||||||
New annotation ({pending.selectors.length} selector{pending.selectors.length === 1 ? "" : "s"})
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
|
|
||||||
“{shortQuote}”
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={commentary}
|
|
||||||
onChange={(e) => setCommentary(e.target.value)}
|
|
||||||
placeholder="Add a one-line comment (optional)…"
|
|
||||||
rows={2}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
fontSize: 12,
|
|
||||||
padding: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
|
||||||
<button onClick={handleSave} style={{ fontSize: 12, padding: "4px 10px" }}>
|
|
||||||
Save evidence
|
|
||||||
</button>
|
|
||||||
<button onClick={handleDiscard} style={{ fontSize: 12, padding: "4px 10px" }}>
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -106,13 +106,16 @@ export function CollectionList({ upload, title }: CollectionListProps) {
|
|||||||
<li key={doc.id} style={{ marginBottom: 6 }}>
|
<li key={doc.id} style={{ marginBottom: 6 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid #ccc",
|
// Always-blue palette to mirror the evidence-card
|
||||||
background: isActive ? "#e8f0ff" : "white",
|
// "always-yellow + thicker border when active" pattern.
|
||||||
|
border: isActive ? "3px solid #0050b3" : "1px solid #0050b3",
|
||||||
|
background: "#e8f0ff",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
data-testid={`collection-item-${doc.id}`}
|
data-testid={`collection-item-${doc.id}`}
|
||||||
|
data-active={isActive ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setId(doc.id)}
|
onClick={() => setId(doc.id)}
|
||||||
|
|||||||
104
src/work/EvidenceFormBody.tsx
Normal file
104
src/work/EvidenceFormBody.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* EvidenceFormBody — the shared "citation + commentary" editor.
|
||||||
|
*
|
||||||
|
* One form drives both:
|
||||||
|
* - the *capture* flow (creating evidence from a fresh selection
|
||||||
|
* inside `InlineCaptureForm`), and
|
||||||
|
* - the *edit* flow (modifying an existing evidence card inside
|
||||||
|
* `EvidenceSidebar`).
|
||||||
|
*
|
||||||
|
* Keeping a single component means changes to the field layout, hint
|
||||||
|
* placement, or save-button copy land in one file and apply to both
|
||||||
|
* surfaces. Callers control the labels and any badge/helper text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface EvidenceFormBodyProps {
|
||||||
|
readonly quote: string;
|
||||||
|
readonly commentary: string;
|
||||||
|
onChangeQuote(next: string): void;
|
||||||
|
onChangeCommentary(next: string): void;
|
||||||
|
onSave(): void;
|
||||||
|
onCancel(): void;
|
||||||
|
/** Save-button label, defaults to "Save". */
|
||||||
|
readonly saveLabel?: string;
|
||||||
|
/** Cancel-button label, defaults to "Cancel". */
|
||||||
|
readonly cancelLabel?: string;
|
||||||
|
/** Short caption rendered at the top of the form, e.g. a selector
|
||||||
|
* count badge or "Editing". */
|
||||||
|
readonly badge?: ReactNode;
|
||||||
|
/** Inline note shown under the buttons (e.g. "won't move the
|
||||||
|
* marked passage" when editing an existing item). */
|
||||||
|
readonly helper?: ReactNode;
|
||||||
|
/** data-testid prefix for the input + button hooks. */
|
||||||
|
readonly testidPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvidenceFormBody(p: EvidenceFormBodyProps) {
|
||||||
|
const saveLabel = p.saveLabel ?? "Save";
|
||||||
|
const cancelLabel = p.cancelLabel ?? "Cancel";
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8, fontSize: 12 }}>
|
||||||
|
{p.badge && (
|
||||||
|
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
|
||||||
|
)}
|
||||||
|
<label style={labelStyle}>Citation text</label>
|
||||||
|
<textarea
|
||||||
|
value={p.quote}
|
||||||
|
onChange={(e) => p.onChangeQuote(e.target.value)}
|
||||||
|
data-testid={`${p.testidPrefix}-quote`}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
...textareaStyle,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label style={labelStyle}>Commentary</label>
|
||||||
|
<textarea
|
||||||
|
value={p.commentary}
|
||||||
|
onChange={(e) => p.onChangeCommentary(e.target.value)}
|
||||||
|
data-testid={`${p.testidPrefix}-commentary`}
|
||||||
|
rows={2}
|
||||||
|
placeholder="(optional)"
|
||||||
|
style={textareaStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onSave}
|
||||||
|
data-testid={`${p.testidPrefix}-save`}
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onCancel}
|
||||||
|
data-testid={`${p.testidPrefix}-cancel`}
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
{p.helper && (
|
||||||
|
<span style={{ fontSize: 11, color: "#888" }}>{p.helper}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const textareaStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* EvidenceSidebar — the right pane.
|
* EvidenceSidebar — the right pane.
|
||||||
*
|
*
|
||||||
* Lists `EvidenceItem`s scoped to the currently-active document. Each row
|
* Lists `EvidenceItem`s scoped to the active document, sorted by their
|
||||||
* shows quote + commentary + status. Clicking a row emits
|
* position in the document (first PdfRectSelector's page + y). Each row:
|
||||||
* `EvidenceItemActivated` via the engine, which T08 will translate into a
|
|
||||||
* scroll-to-passage in the viewer.
|
|
||||||
*
|
*
|
||||||
* CE-WP-0004-T04 added: a per-item Export popover (Copy as Markdown /
|
* - Click → activates the evidence item (highlights its passage in
|
||||||
* Copy as HTML), a transient toast confirming the copy, and the
|
* the viewer + thickens its border).
|
||||||
* Cmd/Ctrl+Shift+C keyboard shortcut that exports the currently-active
|
* - Edit pencil → inline form to change the citation quote and
|
||||||
* evidence as Markdown.
|
* commentary. The underlying selectors stay untouched, so the
|
||||||
|
* marked passage in the document doesn't move.
|
||||||
|
* - Export popover → copy as Markdown / HTML (CE-WP-0004).
|
||||||
|
*
|
||||||
|
* The "create new evidence from a fresh selection" form
|
||||||
|
* (`InlineCaptureForm`) is slotted into the list at the right
|
||||||
|
* document-flow position whenever there is a pending selection — so a
|
||||||
|
* new capture appears between the cards that bracket it, or at the
|
||||||
|
* top/bottom if it's the first or last passage in the document.
|
||||||
|
*
|
||||||
|
* Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -20,14 +29,18 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
import type { EvidenceItemId } from "@shared/ids";
|
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
|
||||||
|
import type { PdfRectSelector, Selector } from "@shared/selector";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
useEngineRevision,
|
useEngineRevision,
|
||||||
useLastActivatedEvidence,
|
useLastActivatedEvidence,
|
||||||
|
usePendingSelection,
|
||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +48,8 @@ import {
|
|||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
type ExportResult,
|
type ExportResult,
|
||||||
} from "./useExportEvidence";
|
} from "./useExportEvidence";
|
||||||
|
import { EvidenceFormBody } from "./EvidenceFormBody";
|
||||||
|
import { InlineCaptureForm } from "./InlineCaptureForm";
|
||||||
|
|
||||||
const TOAST_TIMEOUT_MS = 2000;
|
const TOAST_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
@@ -45,7 +60,6 @@ export interface EvidenceSidebarProps {
|
|||||||
interface ToastState {
|
interface ToastState {
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
readonly tone: "success" | "error";
|
readonly tone: "success" | "error";
|
||||||
/** Bumps on every new toast so timers don't dismiss the *next* toast. */
|
|
||||||
readonly key: number;
|
readonly key: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,23 +81,84 @@ function describeSuccess(format: ExportFormat): string {
|
|||||||
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
|
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sortable scalar key for "where in the document is this passage".
|
||||||
|
* Page-first, then y-coordinate (0..1 within the page). Returns
|
||||||
|
* Infinity for items without a usable position so they sink to the
|
||||||
|
* bottom. The same scheme is used for `EvidenceItem`s (via their
|
||||||
|
* first annotation) and for the pending selection's capture.
|
||||||
|
*/
|
||||||
|
function docOrderKey(selectors: readonly Selector[]): number {
|
||||||
|
for (const s of selectors) {
|
||||||
|
if (s.type === "PdfRectSelector") {
|
||||||
|
const rect: PdfRectSelector = s;
|
||||||
|
const top = rect.rects[0]?.y ?? 0;
|
||||||
|
return rect.page * 1000 + top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationOrderKey(annotation: Annotation | null): number {
|
||||||
|
if (!annotation) return Number.POSITIVE_INFINITY;
|
||||||
|
return docOrderKey(annotation.selectors);
|
||||||
|
}
|
||||||
|
|
||||||
export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const { document } = useActiveDocument();
|
const { document } = useActiveDocument();
|
||||||
const { scrollTo } = useScrollToAnnotation();
|
const { scrollTo } = useScrollToAnnotation();
|
||||||
const activeId = useLastActivatedEvidence();
|
const activeId = useLastActivatedEvidence();
|
||||||
const { exportItem } = useExportEvidence();
|
const { exportItem } = useExportEvidence();
|
||||||
|
const { pending } = usePendingSelection();
|
||||||
|
|
||||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||||
|
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
|
||||||
const revision = useEngineRevision();
|
const revision = useEngineRevision();
|
||||||
|
|
||||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
// Build the sorted view-model: each item gets its order key + the
|
||||||
if (!document) return [];
|
// first annotation up-front so the render below doesn't have to
|
||||||
return engine.evidence.listByDocument(document.id);
|
// re-resolve them inside the map.
|
||||||
}, [document, engine, createTick, updateTick, revision]);
|
const sortedItems = useMemo(() => {
|
||||||
|
if (!document) return [] as readonly { item: EvidenceItem; annotation: Annotation | null; order: number }[];
|
||||||
|
const items = engine.evidence.listByDocument(document.id);
|
||||||
|
const out = items.map((item) => {
|
||||||
|
const firstAnnId = item.annotationIds[0];
|
||||||
|
const annotation = firstAnnId ? engine.annotations.get(firstAnnId) : null;
|
||||||
|
return { item, annotation, order: annotationOrderKey(annotation) };
|
||||||
|
});
|
||||||
|
out.sort((a, b) => a.order - b.order);
|
||||||
|
return out;
|
||||||
|
}, [
|
||||||
|
document,
|
||||||
|
engine,
|
||||||
|
createTick,
|
||||||
|
updateTick,
|
||||||
|
annotationUpdateTick,
|
||||||
|
revision,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pendingOrder = useMemo<number>(() => {
|
||||||
|
if (!pending) return Number.POSITIVE_INFINITY;
|
||||||
|
const c = pending.capture;
|
||||||
|
return c.page * 1000 + (c.boundingRect?.y ?? 0);
|
||||||
|
}, [pending]);
|
||||||
|
|
||||||
|
// Find the insert position for the pending capture form: first index
|
||||||
|
// whose order > pendingOrder, or sortedItems.length to append.
|
||||||
|
const pendingInsertIndex = useMemo(() => {
|
||||||
|
if (!pending) return -1;
|
||||||
|
for (let i = 0; i < sortedItems.length; i++) {
|
||||||
|
if (sortedItems[i]!.order > pendingOrder) return i;
|
||||||
|
}
|
||||||
|
return sortedItems.length;
|
||||||
|
}, [pending, pendingOrder, sortedItems]);
|
||||||
|
|
||||||
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<EvidenceItemId | null>(null);
|
||||||
|
const [editQuote, setEditQuote] = useState("");
|
||||||
|
const [editCommentary, setEditCommentary] = useState("");
|
||||||
const [toast, setToast] = useState<ToastState | null>(null);
|
const [toast, setToast] = useState<ToastState | null>(null);
|
||||||
const toastKeyRef = useRef(0);
|
const toastKeyRef = useRef(0);
|
||||||
|
|
||||||
@@ -127,6 +202,48 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [activeId, engine, runExport]);
|
}, [activeId, engine, runExport]);
|
||||||
|
|
||||||
|
const activateItem = useCallback(
|
||||||
|
(item: EvidenceItem, firstAnnotationId: AnnotationId | undefined) => {
|
||||||
|
engine.evidence.activate(item.id, "sidebar");
|
||||||
|
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
||||||
|
props.onActivate?.(item);
|
||||||
|
},
|
||||||
|
[engine, scrollTo, props],
|
||||||
|
);
|
||||||
|
|
||||||
|
const beginEdit = useCallback(
|
||||||
|
(item: EvidenceItem, annotation: Annotation | null) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditQuote(annotation?.quote ?? "");
|
||||||
|
setEditCommentary(item.commentary ?? "");
|
||||||
|
setOpenExportFor(null);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelEdit = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveEdit = useCallback(
|
||||||
|
(item: EvidenceItem, annotation: Annotation | null) => {
|
||||||
|
try {
|
||||||
|
if (annotation) {
|
||||||
|
engine.annotations.updateQuote(annotation.id, editQuote);
|
||||||
|
}
|
||||||
|
// updateCommentary expects a string — empty string clears it.
|
||||||
|
engine.evidence.updateCommentary(item.id, editCommentary);
|
||||||
|
setEditingId(null);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
err instanceof Error ? `Save failed: ${err.message}` : "Save failed",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[engine, editQuote, editCommentary, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
style={{
|
style={{
|
||||||
@@ -143,138 +260,62 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
{!document && (
|
{!document && (
|
||||||
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
|
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
|
||||||
)}
|
)}
|
||||||
{document && items.length === 0 && (
|
{document && sortedItems.length === 0 && !pending && (
|
||||||
<p style={{ fontSize: 12, color: "#888" }}>
|
<p style={{ fontSize: 12, color: "#888" }}>
|
||||||
No evidence yet. Select a passage in the viewer to create one.
|
No evidence yet. Drag-select a passage in the viewer to start a
|
||||||
|
capture.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
{items.map((item) => {
|
{sortedItems.map((entry, i) => {
|
||||||
const firstAnnotationId = item.annotationIds[0];
|
const slotForCapture = pendingInsertIndex === i;
|
||||||
const annotation = firstAnnotationId
|
|
||||||
? engine.annotations.get(firstAnnotationId)
|
|
||||||
: null;
|
|
||||||
const quote = annotation?.quote ?? "(no quote)";
|
|
||||||
const isActive = activeId === item.id;
|
|
||||||
const isExportOpen = openExportFor === item.id;
|
|
||||||
return (
|
return (
|
||||||
<li key={item.id} style={{ marginBottom: 8 }}>
|
<Fragment key={entry.item.id}>
|
||||||
<div
|
{slotForCapture && (
|
||||||
style={{
|
<li>
|
||||||
position: "relative",
|
<InlineCaptureForm />
|
||||||
background: isActive ? "#e8f0ff" : "#fff8d6",
|
|
||||||
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
engine.evidence.activate(item.id, "sidebar");
|
|
||||||
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
|
||||||
props.onActivate?.(item);
|
|
||||||
}}
|
|
||||||
aria-current={isActive ? "true" : undefined}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
textAlign: "left",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
padding: 8,
|
|
||||||
paddingRight: 80,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
|
||||||
“{quote.slice(0, 140)}
|
|
||||||
{quote.length > 140 ? "…" : ""}”
|
|
||||||
</div>
|
|
||||||
{item.commentary && (
|
|
||||||
<div style={{ color: "#333", marginBottom: 4 }}>
|
|
||||||
{item.commentary}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
status: {item.status}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={isExportOpen}
|
|
||||||
aria-label="Export evidence item"
|
|
||||||
data-testid={`export-toggle-${item.id}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenExportFor((current) =>
|
|
||||||
current === item.id ? null : item.id,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 6,
|
|
||||||
right: 6,
|
|
||||||
fontSize: 11,
|
|
||||||
padding: "2px 6px",
|
|
||||||
background: "white",
|
|
||||||
border: "1px solid #888",
|
|
||||||
borderRadius: 3,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export ▾
|
|
||||||
</button>
|
|
||||||
{isExportOpen && (
|
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
data-testid={`export-menu-${item.id}`}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 28,
|
|
||||||
right: 6,
|
|
||||||
zIndex: 10,
|
|
||||||
background: "white",
|
|
||||||
border: "1px solid #888",
|
|
||||||
borderRadius: 3,
|
|
||||||
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
|
|
||||||
padding: 4,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 2,
|
|
||||||
minWidth: 160,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenExportFor(null);
|
|
||||||
await runExport(item, "markdown");
|
|
||||||
}}
|
|
||||||
style={menuButtonStyle}
|
|
||||||
>
|
|
||||||
Copy as Markdown
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenExportFor(null);
|
|
||||||
await runExport(item, "html");
|
|
||||||
}}
|
|
||||||
style={menuButtonStyle}
|
|
||||||
>
|
|
||||||
Copy as HTML
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
|
<li style={{ marginBottom: 8 }}>
|
||||||
|
<EvidenceCard
|
||||||
|
item={entry.item}
|
||||||
|
annotation={entry.annotation}
|
||||||
|
isActive={activeId === entry.item.id}
|
||||||
|
isExportOpen={openExportFor === entry.item.id}
|
||||||
|
isEditing={editingId === entry.item.id}
|
||||||
|
editQuote={editQuote}
|
||||||
|
editCommentary={editCommentary}
|
||||||
|
onActivate={() =>
|
||||||
|
activateItem(entry.item, entry.annotation?.id)
|
||||||
|
}
|
||||||
|
onBeginEdit={() => beginEdit(entry.item, entry.annotation)}
|
||||||
|
onChangeQuote={setEditQuote}
|
||||||
|
onChangeCommentary={setEditCommentary}
|
||||||
|
onSaveEdit={() => saveEdit(entry.item, entry.annotation)}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onToggleExport={() =>
|
||||||
|
setOpenExportFor((current) =>
|
||||||
|
current === entry.item.id ? null : entry.item.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCopyMarkdown={async () => {
|
||||||
|
setOpenExportFor(null);
|
||||||
|
await runExport(entry.item, "markdown");
|
||||||
|
}}
|
||||||
|
onCopyHtml={async () => {
|
||||||
|
setOpenExportFor(null);
|
||||||
|
await runExport(entry.item, "html");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{pendingInsertIndex === sortedItems.length && (
|
||||||
|
<li>
|
||||||
|
<InlineCaptureForm />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
@@ -302,6 +343,179 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EvidenceCardProps {
|
||||||
|
readonly item: EvidenceItem;
|
||||||
|
readonly annotation: Annotation | null;
|
||||||
|
readonly isActive: boolean;
|
||||||
|
readonly isExportOpen: boolean;
|
||||||
|
readonly isEditing: boolean;
|
||||||
|
readonly editQuote: string;
|
||||||
|
readonly editCommentary: string;
|
||||||
|
onActivate(): void;
|
||||||
|
onBeginEdit(): void;
|
||||||
|
onChangeQuote(next: string): void;
|
||||||
|
onChangeCommentary(next: string): void;
|
||||||
|
onSaveEdit(): void;
|
||||||
|
onCancelEdit(): void;
|
||||||
|
onToggleExport(): void;
|
||||||
|
onCopyMarkdown(): Promise<void>;
|
||||||
|
onCopyHtml(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceCard(p: EvidenceCardProps) {
|
||||||
|
const quote = p.annotation?.quote ?? "(no quote)";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`evidence-card-${p.item.id}`}
|
||||||
|
data-active={p.isActive ? "true" : "false"}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
background: "#fff8d6",
|
||||||
|
border: p.isActive ? "3px solid #b78b1c" : "1px solid #e0c050",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.isEditing ? (
|
||||||
|
<EvidenceFormBody
|
||||||
|
quote={p.editQuote}
|
||||||
|
commentary={p.editCommentary}
|
||||||
|
onChangeQuote={p.onChangeQuote}
|
||||||
|
onChangeCommentary={p.onChangeCommentary}
|
||||||
|
onSave={p.onSaveEdit}
|
||||||
|
onCancel={p.onCancelEdit}
|
||||||
|
saveLabel="Save"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
helper="The marked passage in the document stays the same."
|
||||||
|
testidPrefix={`evidence-edit-${p.item.id}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onActivate}
|
||||||
|
aria-current={p.isActive ? "true" : undefined}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
padding: 8,
|
||||||
|
paddingRight: 96,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
||||||
|
“{quote.slice(0, 140)}
|
||||||
|
{quote.length > 140 ? "…" : ""}”
|
||||||
|
</div>
|
||||||
|
{p.item.commentary && (
|
||||||
|
<div style={{ color: "#333", marginBottom: 4 }}>
|
||||||
|
{p.item.commentary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: "#666", fontSize: 11 }}>
|
||||||
|
status: {p.item.status}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Edit citation and commentary"
|
||||||
|
data-testid={`evidence-edit-toggle-${p.item.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
p.onBeginEdit();
|
||||||
|
}}
|
||||||
|
title="Edit citation and commentary"
|
||||||
|
style={iconButtonStyle}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={p.isExportOpen}
|
||||||
|
aria-label="Export evidence item"
|
||||||
|
data-testid={`export-toggle-${p.item.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
p.onToggleExport();
|
||||||
|
}}
|
||||||
|
style={iconButtonStyle}
|
||||||
|
>
|
||||||
|
Export ▾
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{p.isExportOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
data-testid={`export-menu-${p.item.id}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 28,
|
||||||
|
right: 6,
|
||||||
|
zIndex: 10,
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid #888",
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
|
||||||
|
padding: 4,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
minWidth: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await p.onCopyMarkdown();
|
||||||
|
}}
|
||||||
|
style={menuButtonStyle}
|
||||||
|
>
|
||||||
|
Copy as Markdown
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await p.onCopyHtml();
|
||||||
|
}}
|
||||||
|
style={menuButtonStyle}
|
||||||
|
>
|
||||||
|
Copy as HTML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconButtonStyle: CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 6px",
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid #888",
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: "pointer",
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
|
|
||||||
const menuButtonStyle: CSSProperties = {
|
const menuButtonStyle: CSSProperties = {
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user