Extract binder package from citation-evidence (EBIND-WP-0001)
Extracts citation-evidence/src/binder/ into this repo as the standalone @citation-evidence/evidence-binder package: headless binding service + in-memory link repo, active-state machine, the SharedContracts §7 rect-registry contract (registry, change pumps, hooks, SVG overlay), and the target-neutral reference FormRenderer. - toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint); imports rewritten from @shared/@engine aliases to the engine's @citation-evidence/engine package specifiers - dependency boundary (engine + anchor only; no source/work/umbrella) enforced via eslint no-restricted-imports - docs: extraction inventory + contract deltas, ADR-0001 (reference UI kept as supported exports), refreshed README/SCOPE/INTENT, populated capabilities index - typecheck + lint green, 37 tests passing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87d4eb3006
commit
a10f080f12
36 changed files with 6643 additions and 170 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -174,3 +174,10 @@ cython_debug/
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# ---> Node / TypeScript (evidence-binder is a pnpm TS package)
|
||||||
|
node_modules/
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
*.tsbuildinfo
|
||||||
|
.vitest/
|
||||||
|
# pnpm-lock.yaml and .nvmrc are intentionally tracked (sibling-repo convention)
|
||||||
|
|
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v20.10.0
|
||||||
17
INTENT.md
17
INTENT.md
|
|
@ -454,15 +454,18 @@ This repository should be:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MVP Coordination — Code Lives Upstream
|
## MVP Coordination — Code Now Extracted Here
|
||||||
|
|
||||||
During the umbrella-first MVP phase (decided 2026-05-24), **the source code
|
During the umbrella-first MVP phase (decided 2026-05-24), the source code for
|
||||||
for this subsystem does not live in this repository yet**. It lives in the
|
this subsystem lived in the umbrella repo at `citation-evidence/src/binder/`.
|
||||||
umbrella repo at `citation-evidence/src/binder/`.
|
**As of EBIND-WP-0001 (2026-07-08) it has been extracted into this repository**
|
||||||
|
under `src/`, and this repo is now the canonical home of the binder
|
||||||
|
implementation. See `docs/extraction-inventory.md` for the extraction boundary
|
||||||
|
and resolved contract deltas, and `README.md` for the current package surface.
|
||||||
|
|
||||||
This INTENT.md documents the *intended* responsibilities and boundaries.
|
This INTENT.md documents the *intended* responsibilities and boundaries; the
|
||||||
When the binding model and visual-guide rect-registry contract have stabilized
|
implemented surface may be narrower than the aspirational target catalogue above
|
||||||
through actual MVP use, the corresponding code extracts into this repository.
|
(the MVP exercises `form-field` targets).
|
||||||
|
|
||||||
**Shared contracts** (EvidenceLink.status enum, EvidenceLink.relation enum,
|
**Shared contracts** (EvidenceLink.status enum, EvidenceLink.relation enum,
|
||||||
EvidenceSet shape, rect registry contract, allowed dependency edges) are
|
EvidenceSet shape, rect registry contract, allowed dependency edges) are
|
||||||
|
|
|
||||||
58
README.md
58
README.md
|
|
@ -1,19 +1,49 @@
|
||||||
# evidence-binder
|
# evidence-binder
|
||||||
|
|
||||||
Connect evidence items to structured targets — form fields, claims,
|
`@citation-evidence/evidence-binder` — connect evidence items to structured
|
||||||
requirements — and render the visual guide that ties a target, its evidence
|
targets (form fields, claims, requirements, …) and render the visual guide that
|
||||||
card, and the source highlight together in the workspace UI.
|
ties a target, its evidence card, and the source highlight together.
|
||||||
|
|
||||||
## MVP status: INTENT only
|
This is the canonical home of the binder implementation, extracted from
|
||||||
|
`citation-evidence/src/binder/` (EBIND-WP-0001).
|
||||||
|
|
||||||
During the citation-evidence MVP, code lives upstream in
|
## What this package owns
|
||||||
[`citation-evidence`](../citation-evidence/) under `src/binder/`. This repo
|
|
||||||
currently holds `INTENT.md` describing what will move here. Contract
|
|
||||||
changes — including the rect-registry contract that the visual guide
|
|
||||||
depends on — belong in
|
|
||||||
[`citation-evidence/wiki/SharedContracts.md`](../citation-evidence/wiki/SharedContracts.md),
|
|
||||||
not here.
|
|
||||||
|
|
||||||
Per the dependency map, binder depends on `shared/`, `engine/`, and
|
- **Evidence links** — `EvidenceLink` repository + binding service
|
||||||
`anchor/`. It must not be imported by `work/` (the UI talks to binder via
|
(`services/bindings.ts`, `repos/in-memory-links.ts`). Two query directions:
|
||||||
the active-state context, not by direct import).
|
*all evidence for a target* and *all targets for an evidence item*. Emits the
|
||||||
|
canonical engine bus events (`EvidenceLinkCreated/Updated/Removed`,
|
||||||
|
`EvidenceItemActivated`).
|
||||||
|
- **Active state** — the `(activeTarget, activeEvidenceItemId, activeAnnotationId)`
|
||||||
|
machine and React provider (`state/active.ts`) that the form UI, evidence
|
||||||
|
sidebar, and viewer adapter coordinate through.
|
||||||
|
- **Rect registry contract** (SharedContracts §7) — a single registry into which
|
||||||
|
independent renderers publish `field` / `evidence-card` / `highlight` rects,
|
||||||
|
plus the change pumps, React hooks, and the SVG `Overlay` that draws the guide
|
||||||
|
(`visual-guide/`).
|
||||||
|
- **Reference form UI** — `FormRenderer` and `FieldDefinitionForm`, kept as
|
||||||
|
supported, target-neutral exports (see `docs/adr/ADR-0001-reference-ui-surface.md`).
|
||||||
|
|
||||||
|
`BinderProvider` composes all four concerns behind one mount.
|
||||||
|
|
||||||
|
## Dependency boundary
|
||||||
|
|
||||||
|
Per `citation-evidence/wiki/DependencyMap.md` §2, this package **may** depend on
|
||||||
|
`citation-engine` and `evidence-anchor`, and **must not** depend on
|
||||||
|
`evidence-source`, `citation-work`, or the umbrella `citation-evidence`. The MVP
|
||||||
|
slice imports only `citation-engine` (shared types + event bus). The boundary is
|
||||||
|
enforced by `eslint.config.js`. Shared-contract authority stays in the umbrella
|
||||||
|
wiki.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # links @citation-evidence/engine from ../citation-engine
|
||||||
|
pnpm test # vitest (node + happy-dom suites)
|
||||||
|
pnpm typecheck # tsc -b --noEmit
|
||||||
|
pnpm lint # eslint (enforces the dependency boundary)
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a sibling checkout of `citation-engine` (see `.nvmrc` for the Node
|
||||||
|
version). See `docs/extraction-inventory.md` for the extraction boundary and
|
||||||
|
resolved contract deltas.
|
||||||
|
|
|
||||||
118
SCOPE.md
118
SCOPE.md
|
|
@ -8,130 +8,118 @@
|
||||||
|
|
||||||
## One-liner
|
## One-liner
|
||||||
|
|
||||||
<!-- Describe the purpose of this repository in one precise sentence. -->
|
Binds evidence items to structured targets and owns the rect-registry contract
|
||||||
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
|
that drives the citation-evidence visual guide.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Idea
|
## Core Idea
|
||||||
|
|
||||||
<!-- What is the main capability or idea behind this repository? -->
|
Turn collected evidence into structured, target-bound relationships — "what does
|
||||||
<!-- What problem does it try to solve? -->
|
this evidence support, explain, contradict, or source?" — and coordinate the
|
||||||
|
active `(target, evidence, annotation)` triple so the workspace can draw a visual
|
||||||
|
guide from a form field to its evidence card to the source highlight.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
<!-- What this repository is responsible for. -->
|
- `EvidenceLink` storage + binding service (link/unlink/update, two query
|
||||||
<!-- Be explicit and concrete. -->
|
directions, canonical bus events)
|
||||||
|
- active-target / active-evidence / active-annotation state machine + provider
|
||||||
-
|
- the SharedContracts §7 rect-registry contract: registry, change pumps, React
|
||||||
-
|
hooks, and the SVG overlay
|
||||||
-
|
- a target-neutral reference `FormRenderer` (+ `FieldDefinitionForm`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
<!-- What this repository deliberately does NOT do. -->
|
- document ingestion, selector creation/resolution, or viewer internals (that is
|
||||||
<!-- This is often more important than "In Scope". -->
|
`evidence-anchor` / the engine)
|
||||||
|
- evidence capture / source panels (`evidence-source`)
|
||||||
-
|
- the review workspace and app composition (`citation-work`, umbrella `app/`)
|
||||||
-
|
- changing canonical enum vocabularies (owned by the umbrella shared contracts)
|
||||||
-
|
- publish/distribution beyond sibling-checkout linking
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Relevant When
|
## Relevant When
|
||||||
|
|
||||||
<!-- When should someone consider using or exploring this repository? -->
|
- linking evidence to form fields, claims, requirements, decisions, or sections
|
||||||
|
- coordinating which target/evidence/annotation is active
|
||||||
-
|
- drawing or consuming the visual guide between panes
|
||||||
-
|
|
||||||
-
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Not Relevant When
|
## Not Relevant When
|
||||||
|
|
||||||
<!-- When should someone ignore this repository? -->
|
- you need to import, render, or re-anchor documents (use `evidence-anchor`)
|
||||||
|
- you need to capture or ingest evidence sources (use `evidence-source`)
|
||||||
-
|
- you are wiring the whole app together (that is the umbrella `citation-evidence`)
|
||||||
-
|
|
||||||
-
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
<!-- Rough indication of maturity. No strict format required. -->
|
- Status: active
|
||||||
|
- Implementation: substantial (headless core + rect-registry + reference UI extracted, tests green)
|
||||||
- Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
|
- Stability: evolving
|
||||||
- Implementation: <!-- e.g. idea / partial / substantial / complete -->
|
- Usage: internal (consumed by the citation-evidence umbrella)
|
||||||
- Stability: <!-- e.g. unstable / evolving / stable -->
|
|
||||||
- Usage: <!-- e.g. none / personal / internal / production -->
|
|
||||||
|
|
||||||
<!-- Add any notes that help set expectations. -->
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Fits
|
## How It Fits
|
||||||
|
|
||||||
<!-- Where does this repository sit in the bigger picture? -->
|
- Upstream dependencies: `citation-engine` (shared types + event bus); `evidence-anchor` (allowed, currently unused)
|
||||||
|
- Downstream consumers: `citation-evidence` umbrella app
|
||||||
- Upstream dependencies:
|
- Often used with: `evidence-anchor`, `citation-engine`
|
||||||
- Downstream consumers:
|
|
||||||
- Often used with:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Terminology
|
## Terminology
|
||||||
|
|
||||||
<!-- Terms that are important to understand this repo. -->
|
- Preferred terms: evidence link, evidence target, rect registry, active triple, visual guide
|
||||||
<!-- Especially useful if naming differs from other repos. -->
|
- Also known as: "binder"
|
||||||
|
- Potentially confusing terms: dropped legacy relations `derived-from` / `needs-check` are NOT part of the canonical `EvidenceRelation` enum
|
||||||
- Preferred terms:
|
|
||||||
- Also known as:
|
|
||||||
- Potentially confusing terms:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related / Overlapping Repositories
|
## Related / Overlapping Repositories
|
||||||
|
|
||||||
<!-- List repositories that have similar or adjacent responsibilities. -->
|
- citation-engine — owns shared domain types + the event bus this package emits on
|
||||||
<!-- Helps detect duplication and navigate the ecosystem. -->
|
- evidence-anchor — selector/resolution + highlight/scroll contracts (allowed dependency)
|
||||||
|
- citation-evidence — umbrella app that composes the binder with the rest of the workspace
|
||||||
- <repo-name> — <!-- how it relates -->
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Oriented
|
## Getting Oriented
|
||||||
|
|
||||||
<!-- If someone decides to look deeper, where should they start? -->
|
- Start with: `README.md`, then `docs/extraction-inventory.md`
|
||||||
|
- Key files / directories: `src/services/bindings.ts`, `src/state/active.ts`, `src/visual-guide/rect-registry.ts`, `src/BinderProvider.tsx`
|
||||||
- Start with:
|
- Entry points: `src/index.ts`
|
||||||
- Key files / directories:
|
|
||||||
- Entry points:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Provided Capabilities
|
## 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
|
```capability
|
||||||
type: infrastructure
|
type: library
|
||||||
title: Example capability title
|
title: Evidence-to-target binding
|
||||||
description: What this capability provides, in one or two sentences.
|
description: Links evidence items to structured targets (form fields, claims, requirements) with relation/status/confidence and two query directions, emitting canonical engine bus events.
|
||||||
keywords: [keyword1, keyword2, keyword3]
|
keywords: [evidence, binding, evidence-link, form-field, citation-evidence]
|
||||||
|
```
|
||||||
|
|
||||||
|
```capability
|
||||||
|
type: library
|
||||||
|
title: Visual-guide rect registry
|
||||||
|
description: The SharedContracts §7 rect-registry contract — independent renderers publish field/evidence-card/highlight rects into one registry; an SVG overlay draws the active-triple guide without polling.
|
||||||
|
keywords: [rect-registry, visual-guide, overlay, active-state, citation-evidence]
|
||||||
```
|
```
|
||||||
-->
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
<!-- Anything else worth knowing. Keep it short. -->
|
Extracted from `citation-evidence/src/binder/` under EBIND-WP-0001. Shared-contract
|
||||||
|
authority remains in the umbrella wiki.
|
||||||
|
|
|
||||||
49
docs/adr/ADR-0001-reference-ui-surface.md
Normal file
49
docs/adr/ADR-0001-reference-ui-surface.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# ADR-0001 — Binder-owned reference UI surface
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-07-08
|
||||||
|
- Workplan: EBIND-WP-0001 (T05)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The extracted binder slice includes two small React components:
|
||||||
|
|
||||||
|
- `FormRenderer.tsx` — renders a `FormSchema` as an evidence-backed form. Each
|
||||||
|
field registers itself with the rect registry under `kind="field"`, focuses a
|
||||||
|
target via the active-state machine, and shows a link-count chip. This is the
|
||||||
|
component that produces the `field` leg of the visual-guide triple.
|
||||||
|
- `FieldDefinitionForm.tsx` — a shared label/type editor used by `FormRenderer`'s
|
||||||
|
add-field and edit-field flows.
|
||||||
|
|
||||||
|
The architectural rule is that the binder is *form-friendly but target-neutral*:
|
||||||
|
it must not depend on `citation-work` and must not assume any particular host
|
||||||
|
application.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Keep both components as supported binder exports.**
|
||||||
|
|
||||||
|
- `FormRenderer` is contractual: it is the reference implementation of the
|
||||||
|
"form field publishes a `field` rect and drives active-target focus" half of
|
||||||
|
the rect-registry contract (SharedContracts §7). Demoting it to an example
|
||||||
|
would leave the §7 field-side contract without an in-repo reference.
|
||||||
|
- `FieldDefinitionForm` is retained as a supporting export because `FormRenderer`
|
||||||
|
depends on it directly; splitting them across the package/example boundary
|
||||||
|
would break that import.
|
||||||
|
|
||||||
|
Both remain **target-neutral**: they operate purely on `FormSchema` /
|
||||||
|
`EvidenceTarget` and the binder's own hooks. They take no dependency on
|
||||||
|
`citation-work`, `evidence-source`, or the umbrella. The umbrella owns the
|
||||||
|
*composition* (wiring `FormRenderer` to persistence, sidebars, and the viewer)
|
||||||
|
in `citation-evidence/src/app/**`; the binder owns only the renderer itself.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `src/index.ts` continues to export `FormRenderer` (and its
|
||||||
|
`FormSchema` / `FormFieldSchema` / `FormRendererProps` types); the umbrella
|
||||||
|
imports them unchanged after cutover.
|
||||||
|
- The package surface stays "headless core + rect-registry contract + one
|
||||||
|
reference form renderer" rather than a broad UI kit. Future richer UI belongs
|
||||||
|
in host apps, not here.
|
||||||
|
- If a later workplan needs a genuinely illustrative demo, it goes under an
|
||||||
|
`examples/` tree, not the package root, so the subsystem boundary stays sharp.
|
||||||
73
docs/extraction-inventory.md
Normal file
73
docs/extraction-inventory.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Extraction inventory & contract deltas (EBIND-WP-0001-T01)
|
||||||
|
|
||||||
|
Locks the extraction boundary for pulling `citation-evidence/src/binder/` into
|
||||||
|
this repository. Authority for the shared contracts remains the umbrella wiki
|
||||||
|
(`../citation-evidence/wiki/SharedContracts.md`,
|
||||||
|
`../citation-evidence/wiki/DependencyMap.md`) until the cutover is complete.
|
||||||
|
|
||||||
|
## 1. Files that extract into this repo
|
||||||
|
|
||||||
|
All of `citation-evidence/src/binder/` moves to `evidence-binder/src/`:
|
||||||
|
|
||||||
|
| Concern | Files |
|
||||||
|
| --- | --- |
|
||||||
|
| Headless link repo | `repos/in-memory-links.ts`, `repos/index.ts` |
|
||||||
|
| Binding service | `services/bindings.ts` (+ `.test.ts`), `services/index.ts` |
|
||||||
|
| Active-state machine | `state/active.ts` (+ `.test.ts`), `state/index.ts` |
|
||||||
|
| Provider composition | `BinderProvider.tsx` |
|
||||||
|
| Visual guide (rect registry) | `visual-guide/rect-registry.ts` (+ `.test.ts`), `visual-guide/events.ts`, `visual-guide/react-hooks.ts` (+ `.dom.test.tsx`), `visual-guide/Overlay.tsx` (+ `.dom.test.tsx`), `visual-guide/index.ts` |
|
||||||
|
| Reference UI | `FormRenderer.tsx` (+ `FormRenderer.dom.test.tsx`), `FieldDefinitionForm.tsx` |
|
||||||
|
| Barrel | `index.ts` |
|
||||||
|
|
||||||
|
The upstream `src/binder/README.md` is **not** carried over — this repo's
|
||||||
|
top-level `README.md` supersedes it.
|
||||||
|
|
||||||
|
## 2. Files that remain umbrella-only
|
||||||
|
|
||||||
|
Everything that composes binder with the rest of the app stays in the umbrella
|
||||||
|
(`citation-evidence/src/app/**`): `FormsApp.tsx`, `HighlightRectBridge.tsx`,
|
||||||
|
`ActiveEvidenceChips.tsx`, `CaptureLinkPersister.tsx`, `capture-persistence.ts`,
|
||||||
|
`App.tsx`. These are consumers of the binder, not part of it.
|
||||||
|
|
||||||
|
## 3. Import-alias rewrite
|
||||||
|
|
||||||
|
Upstream binder files used the umbrella's internal path aliases. On extraction
|
||||||
|
they are rewritten to the engine's published package specifiers (the same
|
||||||
|
convention `evidence-anchor` uses):
|
||||||
|
|
||||||
|
| Upstream alias | Extracted specifier |
|
||||||
|
| --- | --- |
|
||||||
|
| `@shared/evidence-link`, `@shared/ids` | `@citation-evidence/engine/shared` |
|
||||||
|
| `@engine/events` | `@citation-evidence/engine` |
|
||||||
|
|
||||||
|
No source imported `@binder/*`, `@work/*`, `@source/*`, or `@app/*`, so no
|
||||||
|
forbidden edge existed in the slice. The binder MVP slice imports **only** from
|
||||||
|
`citation-engine` (shared types + event bus). It does not currently import
|
||||||
|
`evidence-anchor`, which is an *allowed but unused* edge (DependencyMap §2).
|
||||||
|
|
||||||
|
## 4. Contract deltas resolved
|
||||||
|
|
||||||
|
- **`EvidenceTarget` naming / target vocabulary.** No drift. The engine's
|
||||||
|
`EvidenceTarget` (`{ targetType, targetId }`) and the closed
|
||||||
|
`EvidenceTargetType` catalogue (`form-field`, `claim`, `requirement`,
|
||||||
|
`decision`, `document-section`) are canonical and consumed as-is. The MVP only
|
||||||
|
exercises `form-field`. `INTENT.md` previously listed extra target kinds
|
||||||
|
("tasks, or other information objects"); that is aspirational prose, not a
|
||||||
|
competing enum, and is left as narrative.
|
||||||
|
- **Dropped relation values `derived-from` / `needs-check`.** These are **not**
|
||||||
|
members of the canonical `EvidenceRelation` enum (`supports`, `contradicts`,
|
||||||
|
`explains`, `qualifies`, `source-for`, `context-for`). The extracted package
|
||||||
|
does **not** reintroduce them; any earlier INTENT mention of them is dropped
|
||||||
|
from the implemented surface.
|
||||||
|
- **Event vocabulary / removal semantics.** The binding service emits only
|
||||||
|
canonical bus events: `EvidenceLinkCreated`, `EvidenceLinkUpdated`,
|
||||||
|
`EvidenceLinkRemoved` (hard-delete; rejected-status path deferred), and
|
||||||
|
`EvidenceItemActivated` / `FormFieldActivated`. All are members of the engine's
|
||||||
|
`EngineEvent` union — no local event vocabulary is introduced.
|
||||||
|
|
||||||
|
## 5. Forbidden dependency edges (called out before copy)
|
||||||
|
|
||||||
|
Per DependencyMap §2, `evidence-binder` may depend on `citation-engine` and
|
||||||
|
`evidence-anchor`, and must **not** depend on `evidence-source`,
|
||||||
|
`citation-work`, or the umbrella `citation-evidence`. This boundary is enforced
|
||||||
|
mechanically by `eslint.config.js` (`no-restricted-imports`).
|
||||||
54
eslint.config.js
Normal file
54
eslint.config.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// ESLint flat config — enforces the DependencyMap §2 edge set for
|
||||||
|
// `evidence-binder`: it may depend on `citation-engine` and `evidence-anchor`,
|
||||||
|
// and must NOT depend on `evidence-source`, `citation-work`, or the umbrella
|
||||||
|
// `citation-evidence`.
|
||||||
|
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import importPlugin from "eslint-plugin-import";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
// Package specifiers and relative-path prefixes that would cross a forbidden
|
||||||
|
// dependency edge. Anchor and engine are the only allowed sibling edges.
|
||||||
|
const FORBIDDEN_EDGES = [
|
||||||
|
{ name: "@citation-evidence/evidence-source", message: "binder must not depend on evidence-source (DependencyMap §2)." },
|
||||||
|
{ name: "@citation-evidence/work", message: "binder must not depend on citation-work (DependencyMap §2)." },
|
||||||
|
{ name: "@citation-evidence/citation-work", message: "binder must not depend on citation-work (DependencyMap §2)." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORBIDDEN_PATTERNS = [
|
||||||
|
{ group: ["../evidence-source/*", "../citation-work/*", "../citation-evidence/*"], message: "binder must not reach into evidence-source, citation-work, or the umbrella (DependencyMap §2)." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "node_modules/", "coverage/", "**/*.d.ts"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
globals: { ...globals.node, ...globals.browser },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
import: importPlugin,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"import/resolver": {
|
||||||
|
typescript: { project: "./tsconfig.json" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
paths: FORBIDDEN_EDGES,
|
||||||
|
patterns: FORBIDDEN_PATTERNS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
54
package.json
Normal file
54
package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"name": "@citation-evidence/evidence-binder",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Evidence-to-target binding, active-state coordination, and the visual-guide rect-registry contract for the citation-evidence ecosystem.",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.15.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.10.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"SCOPE.md",
|
||||||
|
"INTENT.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc -b --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@citation-evidence/engine": "link:../citation-engine"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"eslint": "^9.7.0",
|
||||||
|
"eslint-import-resolver-typescript": "^3.6.3",
|
||||||
|
"eslint-plugin-import": "^2.30.0",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"happy-dom": "^20.9.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
4006
pnpm-lock.yaml
generated
Normal file
4006
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
repo: evidence-binder
|
|
||||||
reason: >
|
|
||||||
Only INTENT/SCOPE/registry/workplans; the binding model is described but not implemented.
|
|
||||||
reviewed: "2026-07-06"
|
|
||||||
reviewed_by: claude-code
|
|
||||||
revisit: >
|
|
||||||
Revisit once the binding model has a real implementation.
|
|
||||||
---
|
|
||||||
|
|
||||||
# No reusable capability
|
|
||||||
|
|
||||||
This repo was reviewed for the `reuse-surface` capability registry
|
|
||||||
(REUSE-WP-0017 coverage campaign) and has no capability to register at this
|
|
||||||
time. See the `reason` field above.
|
|
||||||
|
|
@ -1,4 +1,18 @@
|
||||||
version: 1
|
version: 1
|
||||||
updated: '2026-06-16'
|
updated: '2026-07-08'
|
||||||
domain: helix_forge
|
domain: helix_forge
|
||||||
capabilities: []
|
capabilities:
|
||||||
|
- type: library
|
||||||
|
title: Evidence-to-target binding
|
||||||
|
description: >-
|
||||||
|
Links evidence items to structured targets (form fields, claims,
|
||||||
|
requirements) with relation/status/confidence and two query directions,
|
||||||
|
emitting canonical engine bus events.
|
||||||
|
keywords: [evidence, binding, evidence-link, form-field, citation-evidence]
|
||||||
|
- type: library
|
||||||
|
title: Visual-guide rect registry
|
||||||
|
description: >-
|
||||||
|
The SharedContracts §7 rect-registry contract — independent renderers
|
||||||
|
publish field/evidence-card/highlight rects into one registry; an SVG
|
||||||
|
overlay draws the active-triple guide without polling.
|
||||||
|
keywords: [rect-registry, visual-guide, overlay, active-state, citation-evidence]
|
||||||
|
|
|
||||||
119
src/BinderProvider.tsx
Normal file
119
src/BinderProvider.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* BinderProvider — composition root for the binder subsystem.
|
||||||
|
*
|
||||||
|
* Wires the four binder concerns (rect registry, binding service, link
|
||||||
|
* repo, active state machine) into one provider so a single mount inside
|
||||||
|
* the EngineProvider gives every binder consumer (FormRenderer, evidence
|
||||||
|
* picker, SVG overlay) what it needs.
|
||||||
|
*
|
||||||
|
* The provider is split out from the engine because in a future
|
||||||
|
* subsystem-extraction these will live in separate packages — the engine
|
||||||
|
* will publish only the event bus and the engine services, while
|
||||||
|
* `evidence-binder` will export this provider.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import type { EvidenceLink } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import type { EventBus } from "@citation-evidence/engine";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActiveStateProvider,
|
||||||
|
useActiveState,
|
||||||
|
} from "./state/active";
|
||||||
|
import {
|
||||||
|
createInMemoryLinkRepo,
|
||||||
|
type EvidenceLinkRepository,
|
||||||
|
} from "./repos/in-memory-links";
|
||||||
|
import {
|
||||||
|
createBindingService,
|
||||||
|
type BindingService,
|
||||||
|
} from "./services/bindings";
|
||||||
|
import {
|
||||||
|
RectRegistryProvider,
|
||||||
|
createRectRegistryContextValue,
|
||||||
|
type RectRegistryContextValue,
|
||||||
|
} from "./visual-guide/react-hooks";
|
||||||
|
|
||||||
|
export interface BinderServices {
|
||||||
|
readonly links: EvidenceLinkRepository;
|
||||||
|
readonly bindings: BindingService;
|
||||||
|
readonly rect: RectRegistryContextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BinderServicesContext = createContext<BinderServices | null>(null);
|
||||||
|
|
||||||
|
export function useBinder(): BinderServices {
|
||||||
|
const ctx = useContext(BinderServicesContext);
|
||||||
|
if (!ctx) throw new Error("useBinder: missing <BinderProvider />");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinderProviderProps {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
/**
|
||||||
|
* The engine's event bus, threaded in by the composition root so the
|
||||||
|
* binder can emit §4 events without importing work/EngineContext
|
||||||
|
* (work cannot be a dependency of binder — see DependencyMap §2).
|
||||||
|
*/
|
||||||
|
readonly bus: EventBus;
|
||||||
|
/**
|
||||||
|
* Tests can inject a pre-built service set; production constructs a
|
||||||
|
* fresh one. The rect registry is *always* fresh per provider mount
|
||||||
|
* because its observers attach to the current `window`.
|
||||||
|
*/
|
||||||
|
readonly services?: Omit<BinderServices, "rect">;
|
||||||
|
/**
|
||||||
|
* Restored evidence links for this session. Seeded directly into the
|
||||||
|
* repo (no bus events) so reload does not spuriously re-emit
|
||||||
|
* `EvidenceLinkCreated`.
|
||||||
|
*/
|
||||||
|
readonly initialLinks?: readonly EvidenceLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BinderProvider({
|
||||||
|
children,
|
||||||
|
bus,
|
||||||
|
services,
|
||||||
|
initialLinks,
|
||||||
|
}: BinderProviderProps) {
|
||||||
|
const built = useMemo<BinderServices>(() => {
|
||||||
|
const links = services?.links ?? createInMemoryLinkRepo();
|
||||||
|
const bindings = services?.bindings ?? createBindingService(links, bus);
|
||||||
|
const rect = createRectRegistryContextValue();
|
||||||
|
return { links, bindings, rect };
|
||||||
|
}, [bus, services]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialLinks?.length || services?.links) return;
|
||||||
|
for (const link of initialLinks) {
|
||||||
|
if (!built.links.get(link.id)) {
|
||||||
|
built.links.create(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [built.links, initialLinks, services?.links]);
|
||||||
|
|
||||||
|
// Disconnect rect observers + listeners on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
built.rect.observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [built.rect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BinderServicesContext.Provider value={built}>
|
||||||
|
<RectRegistryProvider value={built.rect}>
|
||||||
|
<ActiveStateProvider bus={bus}>{children}</ActiveStateProvider>
|
||||||
|
</RectRegistryProvider>
|
||||||
|
</BinderServicesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useActiveState };
|
||||||
117
src/FieldDefinitionForm.tsx
Normal file
117
src/FieldDefinitionForm.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11).
|
||||||
|
* Styled to match EvidenceFormBody / InlineCaptureForm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { FormFieldSchema } from "./FormRenderer";
|
||||||
|
|
||||||
|
export type FieldType = FormFieldSchema["type"];
|
||||||
|
|
||||||
|
const FIELD_TYPES: readonly { value: FieldType; label: string }[] = [
|
||||||
|
{ value: "text", label: "Text" },
|
||||||
|
{ value: "textarea", label: "Text area" },
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface FieldDefinitionFormProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly type: FieldType;
|
||||||
|
onChangeLabel(next: string): void;
|
||||||
|
onChangeType(next: FieldType): void;
|
||||||
|
onSave(): void;
|
||||||
|
onCancel(): void;
|
||||||
|
readonly saveLabel?: string;
|
||||||
|
readonly cancelLabel?: string;
|
||||||
|
readonly badge?: ReactNode;
|
||||||
|
readonly testidPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
||||||
|
const saveLabel = p.saveLabel ?? "Save";
|
||||||
|
const cancelLabel = p.cancelLabel ?? "Cancel";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`${p.testidPrefix}-form`}
|
||||||
|
style={{
|
||||||
|
border: "1px dashed #b78b1c",
|
||||||
|
background: "#fff8d6",
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.badge && (
|
||||||
|
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
|
||||||
|
)}
|
||||||
|
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
|
||||||
|
Field label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${p.testidPrefix}-label`}
|
||||||
|
type="text"
|
||||||
|
value={p.label}
|
||||||
|
onChange={(e) => p.onChangeLabel(e.target.value)}
|
||||||
|
data-testid={`${p.testidPrefix}-label-input`}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
|
||||||
|
Field type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${p.testidPrefix}-type`}
|
||||||
|
value={p.type}
|
||||||
|
onChange={(e) => p.onChangeType(e.target.value as FieldType)}
|
||||||
|
data-testid={`${p.testidPrefix}-type-select`}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{FIELD_TYPES.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onSave}
|
||||||
|
data-testid={`${p.testidPrefix}-save`}
|
||||||
|
style={buttonStyle}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onCancel}
|
||||||
|
data-testid={`${p.testidPrefix}-cancel`}
|
||||||
|
style={buttonStyle}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
};
|
||||||
114
src/FormRenderer.dom.test.tsx
Normal file
114
src/FormRenderer.dom.test.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* FormRenderer (CE-WP-0003-T04) — happy-dom test covering:
|
||||||
|
* - schema → DOM (3 demo fields render with their labels)
|
||||||
|
* - each field registers with rect registry as kind="field"
|
||||||
|
* - focusing a field calls activeState.focusTarget and emits FormFieldActivated
|
||||||
|
* - typing in a field invokes onValueChange
|
||||||
|
* - linkCounts shows the chip when > 0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createEventBus, type EngineEvent } from "@citation-evidence/engine";
|
||||||
|
|
||||||
|
import { FormRenderer, type FormSchema } from "./FormRenderer";
|
||||||
|
import {
|
||||||
|
ActiveStateProvider,
|
||||||
|
} from "./state/active";
|
||||||
|
import {
|
||||||
|
RectRegistryProvider,
|
||||||
|
createRectRegistryContextValue,
|
||||||
|
} from "./visual-guide/react-hooks";
|
||||||
|
|
||||||
|
const SCHEMA: FormSchema = {
|
||||||
|
id: "demo",
|
||||||
|
title: "Demo form",
|
||||||
|
fields: [
|
||||||
|
{ type: "textarea", id: "summary", label: "Summary" },
|
||||||
|
{ type: "date", id: "deadline", label: "Deadline" },
|
||||||
|
{ type: "text", id: "amount", label: "Amount" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderWithProviders(props: Parameters<typeof FormRenderer>[0]) {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const events: EngineEvent[] = [];
|
||||||
|
bus.onAny((e) => events.push(e));
|
||||||
|
const ctxValue = createRectRegistryContextValue();
|
||||||
|
const utils = render(
|
||||||
|
<RectRegistryProvider value={ctxValue}>
|
||||||
|
<ActiveStateProvider bus={bus}>
|
||||||
|
<FormRenderer {...props} />
|
||||||
|
</ActiveStateProvider>
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
return { ...utils, ctxValue, bus, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FormRenderer (CE-WP-0003-T04)", () => {
|
||||||
|
let cleanupCtx: (() => void) | null = null;
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanupCtx = null;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupCtx?.();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each schema field with its label", () => {
|
||||||
|
renderWithProviders({ schema: SCHEMA });
|
||||||
|
expect(screen.getByLabelText("Summary")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("Deadline")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("Amount")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers each field with the rect registry as kind=field", () => {
|
||||||
|
const { ctxValue } = renderWithProviders({ schema: SCHEMA });
|
||||||
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
|
const list = ctxValue.registry.list();
|
||||||
|
expect(list).toHaveLength(3);
|
||||||
|
expect(list.every((r) => r.kind === "field")).toBe(true);
|
||||||
|
expect(list.map((r) => r.id).sort()).toEqual(["amount", "deadline", "summary"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focusing a field emits FormFieldActivated with the right target", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
|
||||||
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
|
await user.click(screen.getByLabelText("Summary"));
|
||||||
|
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
|
||||||
|
expect(fieldEvents).toHaveLength(1);
|
||||||
|
expect(fieldEvents[0]).toMatchObject({
|
||||||
|
target: { targetType: "form-field", targetId: "summary" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing forwards onValueChange with the field id + new value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const changes: [string, string][] = [];
|
||||||
|
const { ctxValue } = renderWithProviders({
|
||||||
|
schema: SCHEMA,
|
||||||
|
onValueChange: (id, value) => changes.push([id, value]),
|
||||||
|
});
|
||||||
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
|
await user.type(screen.getByLabelText("Amount"), "42");
|
||||||
|
expect(changes).toEqual([
|
||||||
|
["amount", "4"],
|
||||||
|
["amount", "2"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
|
||||||
|
const { ctxValue } = renderWithProviders({
|
||||||
|
schema: SCHEMA,
|
||||||
|
linkCounts: { summary: 2, amount: 0 },
|
||||||
|
});
|
||||||
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
|
expect(screen.queryByTestId("field-summary-chip")).not.toBeNull();
|
||||||
|
expect(screen.queryByTestId("field-amount-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
324
src/FormRenderer.tsx
Normal file
324
src/FormRenderer.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
/**
|
||||||
|
* FormRenderer — renders a FormSchema as a small evidence-backed form.
|
||||||
|
*
|
||||||
|
* Each field registers itself with the rect registry under
|
||||||
|
* `kind="field"` and the field's `id`, so the SVG visual guide (T07) can
|
||||||
|
* draw curves from the active field to its linked evidence card and on
|
||||||
|
* to the source highlight.
|
||||||
|
*
|
||||||
|
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
|
||||||
|
import { useActiveState, type ActiveState } from "./state/active";
|
||||||
|
import { useRegisterRect } from "./visual-guide/react-hooks";
|
||||||
|
|
||||||
|
function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
||||||
|
return (
|
||||||
|
state.activeTarget?.targetType === "form-field" &&
|
||||||
|
state.activeTarget?.targetId === fieldId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormFieldSchema {
|
||||||
|
readonly type: "text" | "textarea" | "date";
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSchema {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly fields: readonly FormFieldSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldDefinitionPatch {
|
||||||
|
readonly label: string;
|
||||||
|
readonly type: FieldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormRendererProps {
|
||||||
|
readonly schema: FormSchema;
|
||||||
|
readonly values?: Readonly<Record<string, string>>;
|
||||||
|
readonly onValueChange?: (fieldId: string, value: string) => void;
|
||||||
|
readonly linkCounts?: Readonly<Record<string, number>>;
|
||||||
|
readonly linkHints?: Readonly<Record<string, string>>;
|
||||||
|
readonly showAddFieldForm?: boolean;
|
||||||
|
readonly onRequestAddField?: () => void;
|
||||||
|
readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void;
|
||||||
|
readonly onCancelAddField?: () => void;
|
||||||
|
readonly editingFieldId?: string | null;
|
||||||
|
readonly onBeginEditField?: (fieldId: string) => void;
|
||||||
|
readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void;
|
||||||
|
readonly onCancelFieldEdit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconButtonStyle: CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 6px",
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid #888",
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: "pointer",
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
linkCount,
|
||||||
|
linkHint,
|
||||||
|
isActive,
|
||||||
|
isEditing,
|
||||||
|
editLabel,
|
||||||
|
editType,
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
onBeginEdit,
|
||||||
|
onChangeEditLabel,
|
||||||
|
onChangeEditType,
|
||||||
|
onSaveEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
}: {
|
||||||
|
field: FormFieldSchema;
|
||||||
|
value: string;
|
||||||
|
linkCount: number;
|
||||||
|
linkHint?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
editLabel: string;
|
||||||
|
editType: FieldType;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onBeginEdit: () => void;
|
||||||
|
onChangeEditLabel: (next: string) => void;
|
||||||
|
onChangeEditType: (next: FieldType) => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useRegisterRect("field", field.id, ref);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-field-id={field.id} style={{ marginBottom: 12 }}>
|
||||||
|
<FieldDefinitionForm
|
||||||
|
label={editLabel}
|
||||||
|
type={editType}
|
||||||
|
onChangeLabel={onChangeEditLabel}
|
||||||
|
onChangeType={onChangeEditType}
|
||||||
|
onSave={onSaveEdit}
|
||||||
|
onCancel={onCancelEdit}
|
||||||
|
saveLabel="Save field"
|
||||||
|
badge="Editing field"
|
||||||
|
testidPrefix={`field-edit-${field.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedProps = {
|
||||||
|
id: `field-${field.id}`,
|
||||||
|
value,
|
||||||
|
onFocus,
|
||||||
|
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
onChange(e.target.value),
|
||||||
|
style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-field-id={field.id}
|
||||||
|
data-link-count={String(linkCount)}
|
||||||
|
aria-current={isActive ? "true" : undefined}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isActive ? "#e8f0ff" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit field ${field.label}`}
|
||||||
|
data-testid={`field-edit-toggle-${field.id}`}
|
||||||
|
title="Edit field label and type"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onBeginEdit();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...iconButtonStyle,
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
htmlFor={sharedProps.id}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingRight: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
{linkCount > 0 ? (
|
||||||
|
<span
|
||||||
|
data-testid={`field-${field.id}-chip`}
|
||||||
|
title={linkHint}
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "#e7f0ff",
|
||||||
|
color: "#0050b3",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{linkCount} evidence
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<textarea rows={2} {...sharedProps} />
|
||||||
|
) : (
|
||||||
|
<input type={field.type === "date" ? "date" : "text"} {...sharedProps} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormRenderer({
|
||||||
|
schema,
|
||||||
|
values,
|
||||||
|
onValueChange,
|
||||||
|
linkCounts,
|
||||||
|
linkHints,
|
||||||
|
showAddFieldForm,
|
||||||
|
onRequestAddField,
|
||||||
|
onConfirmAddField,
|
||||||
|
onCancelAddField,
|
||||||
|
editingFieldId,
|
||||||
|
onBeginEditField,
|
||||||
|
onSaveFieldEdit,
|
||||||
|
onCancelFieldEdit,
|
||||||
|
}: FormRendererProps) {
|
||||||
|
const { state, focusTarget } = useActiveState();
|
||||||
|
const [addLabel, setAddLabel] = useState("New field");
|
||||||
|
const [addType, setAddType] = useState<FieldType>("text");
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
const [editType, setEditType] = useState<FieldType>("text");
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
||||||
|
focusTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEdit = (field: FormFieldSchema) => {
|
||||||
|
setEditLabel(field.label);
|
||||||
|
setEditType(field.type);
|
||||||
|
onBeginEditField?.(field.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-form-id={schema.id}
|
||||||
|
style={{ padding: 12 }}
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{schema.title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="add-field-button"
|
||||||
|
onClick={() => {
|
||||||
|
setAddLabel(`New field ${schema.fields.length + 1}`);
|
||||||
|
setAddType("text");
|
||||||
|
onRequestAddField?.();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "4px 10px",
|
||||||
|
border: "1px solid #888",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddFieldForm && (
|
||||||
|
<FieldDefinitionForm
|
||||||
|
label={addLabel}
|
||||||
|
type={addType}
|
||||||
|
onChangeLabel={setAddLabel}
|
||||||
|
onChangeType={setAddType}
|
||||||
|
onSave={() =>
|
||||||
|
onConfirmAddField?.({
|
||||||
|
label: addLabel.trim(),
|
||||||
|
type: addType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => onCancelAddField?.()}
|
||||||
|
saveLabel="Add field"
|
||||||
|
badge="New form field"
|
||||||
|
testidPrefix="field-add"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schema.fields.map((field) => (
|
||||||
|
<FieldRow
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={values?.[field.id] ?? ""}
|
||||||
|
linkCount={linkCounts?.[field.id] ?? 0}
|
||||||
|
{...(linkHints?.[field.id] != null
|
||||||
|
? { linkHint: linkHints[field.id] }
|
||||||
|
: {})}
|
||||||
|
isActive={isFieldActive(state, field.id)}
|
||||||
|
isEditing={editingFieldId === field.id}
|
||||||
|
editLabel={editLabel}
|
||||||
|
editType={editType}
|
||||||
|
onChange={(next) => onValueChange?.(field.id, next)}
|
||||||
|
onFocus={() => handleFocus(field.id)}
|
||||||
|
onBeginEdit={() => beginEdit(field)}
|
||||||
|
onChangeEditLabel={setEditLabel}
|
||||||
|
onChangeEditType={setEditType}
|
||||||
|
onSaveEdit={() =>
|
||||||
|
onSaveFieldEdit?.(field.id, {
|
||||||
|
label: editLabel.trim(),
|
||||||
|
type: editType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/index.ts
Normal file
12
src/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export * from "./repos";
|
||||||
|
export * from "./services";
|
||||||
|
export * from "./state";
|
||||||
|
export * from "./visual-guide";
|
||||||
|
export { FormRenderer } from "./FormRenderer";
|
||||||
|
export type {
|
||||||
|
FormFieldSchema,
|
||||||
|
FormRendererProps,
|
||||||
|
FormSchema,
|
||||||
|
} from "./FormRenderer";
|
||||||
|
export { BinderProvider, useBinder } from "./BinderProvider";
|
||||||
|
export type { BinderServices, BinderProviderProps } from "./BinderProvider";
|
||||||
BIN
src/repos/in-memory-links.ts
Normal file
BIN
src/repos/in-memory-links.ts
Normal file
Binary file not shown.
1
src/repos/index.ts
Normal file
1
src/repos/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./in-memory-links";
|
||||||
180
src/services/bindings.test.ts
Normal file
180
src/services/bindings.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* Binding service + in-memory link repo tests.
|
||||||
|
*
|
||||||
|
* Exercises every public surface plus the §4 events the service emits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EvidenceLink,
|
||||||
|
EvidenceTarget,
|
||||||
|
} from "@citation-evidence/engine/shared";
|
||||||
|
import type {
|
||||||
|
EvidenceItemId,
|
||||||
|
EvidenceLinkId,
|
||||||
|
} from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import { createEventBus } from "@citation-evidence/engine";
|
||||||
|
import type { EngineEvent } from "@citation-evidence/engine";
|
||||||
|
|
||||||
|
import { createInMemoryLinkRepo } from "../repos/in-memory-links";
|
||||||
|
import { createBindingService } from "./bindings";
|
||||||
|
|
||||||
|
function makeFixture() {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const repo = createInMemoryLinkRepo();
|
||||||
|
const events: EngineEvent[] = [];
|
||||||
|
bus.onAny((e) => events.push(e));
|
||||||
|
let counter = 0;
|
||||||
|
const now = () => `2026-05-25T00:00:0${counter++}.000Z`;
|
||||||
|
const service = createBindingService(repo, bus, now);
|
||||||
|
return { bus, repo, events, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
|
||||||
|
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
|
||||||
|
const EV1 = "ev_test_one" as EvidenceItemId;
|
||||||
|
const EV2 = "ev_test_two" as EvidenceItemId;
|
||||||
|
|
||||||
|
describe("createBindingService", () => {
|
||||||
|
it("linkEvidenceToTarget creates a link, emits EvidenceLinkCreated, and persists it", () => {
|
||||||
|
const { service, repo, events } = makeFixture();
|
||||||
|
|
||||||
|
const link = service.linkEvidenceToTarget({
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
target: FIELD_A,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(link.evidenceItemId).toBe(EV1);
|
||||||
|
expect(link.targetType).toBe("form-field");
|
||||||
|
expect(link.targetId).toBe("summary");
|
||||||
|
expect(link.relation).toBe("supports");
|
||||||
|
expect(link.status).toBe("candidate");
|
||||||
|
expect(link.createdAt).toBe(link.updatedAt);
|
||||||
|
|
||||||
|
expect(repo.get(link.id)).toEqual(link);
|
||||||
|
|
||||||
|
const created = events.filter((e) => e.type === "EvidenceLinkCreated");
|
||||||
|
expect(created).toHaveLength(1);
|
||||||
|
expect(created[0]).toMatchObject({ linkId: link.id, link });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours explicit relation/status/confidence", () => {
|
||||||
|
const { service } = makeFixture();
|
||||||
|
|
||||||
|
const link = service.linkEvidenceToTarget({
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
target: FIELD_A,
|
||||||
|
relation: "contradicts",
|
||||||
|
status: "conflicting",
|
||||||
|
confidence: 0.42,
|
||||||
|
createdBy: "tegwick",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(link.relation).toBe("contradicts");
|
||||||
|
expect(link.status).toBe("conflicting");
|
||||||
|
expect(link.confidence).toBe(0.42);
|
||||||
|
expect(link.createdBy).toBe("tegwick");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listEvidenceForTarget returns only links for the requested target", () => {
|
||||||
|
const { service } = makeFixture();
|
||||||
|
const a1 = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
|
||||||
|
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_B });
|
||||||
|
const a2 = service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
|
||||||
|
|
||||||
|
const linksForA = service.listEvidenceForTarget(FIELD_A);
|
||||||
|
expect(linksForA.map((l) => l.id).sort()).toEqual([a1.id, a2.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listTargetsForEvidence returns all targets an evidence item is linked to", () => {
|
||||||
|
const { service } = makeFixture();
|
||||||
|
const a = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
|
||||||
|
const b = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_B });
|
||||||
|
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
|
||||||
|
|
||||||
|
const targets = service.listTargetsForEvidence(EV1);
|
||||||
|
expect(targets.map((l) => l.id).sort()).toEqual([a.id, b.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlinkEvidence removes the link and reports success/failure", () => {
|
||||||
|
const { service } = makeFixture();
|
||||||
|
const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
|
||||||
|
|
||||||
|
expect(service.unlinkEvidence(link.id)).toBe(true);
|
||||||
|
expect(service.getLink(link.id)).toBeNull();
|
||||||
|
expect(service.unlinkEvidence(link.id)).toBe(false);
|
||||||
|
expect(service.unlinkEvidence("evlink_unknown" as EvidenceLinkId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateLink merges patch, bumps updatedAt, and emits EvidenceLinkUpdated", () => {
|
||||||
|
const { service, events } = makeFixture();
|
||||||
|
const original = service.linkEvidenceToTarget({
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
target: FIELD_A,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = service.updateLink(original.id, {
|
||||||
|
status: "confirmed",
|
||||||
|
confidence: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.status).toBe("confirmed");
|
||||||
|
expect(updated.confidence).toBe(0.9);
|
||||||
|
expect(updated.relation).toBe(original.relation);
|
||||||
|
expect(updated.updatedAt).not.toBe(original.updatedAt);
|
||||||
|
|
||||||
|
const updatedEvents = events.filter((e) => e.type === "EvidenceLinkUpdated");
|
||||||
|
expect(updatedEvents).toHaveLength(1);
|
||||||
|
expect((updatedEvents[0] as Extract<EngineEvent, { type: "EvidenceLinkUpdated" }>).link).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateLink throws on unknown id", () => {
|
||||||
|
const { service } = makeFixture();
|
||||||
|
expect(() =>
|
||||||
|
service.updateLink("evlink_unknown" as EvidenceLinkId, { status: "verified" }),
|
||||||
|
).toThrow(/unknown id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setActiveEvidence emits EvidenceItemActivated with source=form-field", () => {
|
||||||
|
const { service, events } = makeFixture();
|
||||||
|
service.setActiveEvidence(EV1);
|
||||||
|
const activated = events.filter((e) => e.type === "EvidenceItemActivated");
|
||||||
|
expect(activated).toHaveLength(1);
|
||||||
|
expect(activated[0]).toMatchObject({ evidenceItemId: EV1, source: "form-field" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EvidenceLinkRepository (in-memory)", () => {
|
||||||
|
it("rejects duplicate ids on create", () => {
|
||||||
|
const repo = createInMemoryLinkRepo();
|
||||||
|
const link: EvidenceLink = {
|
||||||
|
id: "evlink_x" as EvidenceLinkId,
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
targetType: "form-field",
|
||||||
|
targetId: "f",
|
||||||
|
relation: "supports",
|
||||||
|
status: "candidate",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
repo.create(link);
|
||||||
|
expect(() => repo.create(link)).toThrow(/duplicate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update throws on unknown id", () => {
|
||||||
|
const repo = createInMemoryLinkRepo();
|
||||||
|
const link: EvidenceLink = {
|
||||||
|
id: "evlink_unknown" as EvidenceLinkId,
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
targetType: "form-field",
|
||||||
|
targetId: "f",
|
||||||
|
relation: "supports",
|
||||||
|
status: "candidate",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
expect(() => repo.update(link)).toThrow(/unknown/);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/services/bindings.ts
Normal file
118
src/services/bindings.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* Binding service — links EvidenceItems to structured targets.
|
||||||
|
*
|
||||||
|
* Implements `wiki/ArchitectureOverview.md` §4.6 + SharedContracts §2.4
|
||||||
|
* (status enum), §2.5 (relation enum). Emits §4 events:
|
||||||
|
* `EvidenceLinkCreated`, `EvidenceLinkUpdated`, `EvidenceItemActivated`.
|
||||||
|
*
|
||||||
|
* MVP semantics:
|
||||||
|
* - `linkEvidenceToTarget` defaults `relation="supports"`, `status="candidate"`.
|
||||||
|
* - `unlinkEvidence` is hard-delete; the rejected-status path is left to
|
||||||
|
* a later ADR.
|
||||||
|
* - `setActiveEvidence` emits an `EvidenceItemActivated` event with
|
||||||
|
* `source="form-field"` so the viewer/sidebar can react.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EvidenceLink,
|
||||||
|
EvidenceLinkStoredStatus,
|
||||||
|
EvidenceRelation,
|
||||||
|
EvidenceTarget,
|
||||||
|
} from "@citation-evidence/engine/shared";
|
||||||
|
import type { EvidenceItemId, EvidenceLinkId } from "@citation-evidence/engine/shared";
|
||||||
|
import { newId } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import type { EventBus } from "@citation-evidence/engine";
|
||||||
|
|
||||||
|
import type { EvidenceLinkRepository } from "../repos/in-memory-links";
|
||||||
|
|
||||||
|
export interface LinkEvidenceToTargetInput {
|
||||||
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
|
readonly target: EvidenceTarget;
|
||||||
|
readonly relation?: EvidenceRelation;
|
||||||
|
readonly status?: EvidenceLinkStoredStatus;
|
||||||
|
readonly confidence?: number;
|
||||||
|
readonly createdBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLinkStatusInput {
|
||||||
|
readonly status?: EvidenceLinkStoredStatus;
|
||||||
|
readonly relation?: EvidenceRelation;
|
||||||
|
readonly confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindingService {
|
||||||
|
linkEvidenceToTarget(input: LinkEvidenceToTargetInput): EvidenceLink;
|
||||||
|
unlinkEvidence(id: EvidenceLinkId): boolean;
|
||||||
|
updateLink(id: EvidenceLinkId, input: UpdateLinkStatusInput): EvidenceLink;
|
||||||
|
getLink(id: EvidenceLinkId): EvidenceLink | null;
|
||||||
|
listEvidenceForTarget(target: EvidenceTarget): readonly EvidenceLink[];
|
||||||
|
listTargetsForEvidence(evidenceItemId: EvidenceItemId): readonly EvidenceLink[];
|
||||||
|
setActiveEvidence(evidenceItemId: EvidenceItemId): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBindingService(
|
||||||
|
links: EvidenceLinkRepository,
|
||||||
|
bus: EventBus,
|
||||||
|
now: () => string = () => new Date().toISOString(),
|
||||||
|
): BindingService {
|
||||||
|
return {
|
||||||
|
linkEvidenceToTarget(input) {
|
||||||
|
const ts = now();
|
||||||
|
const link: EvidenceLink = {
|
||||||
|
id: newId("evidence-link"),
|
||||||
|
evidenceItemId: input.evidenceItemId,
|
||||||
|
targetType: input.target.targetType,
|
||||||
|
targetId: input.target.targetId,
|
||||||
|
relation: input.relation ?? "supports",
|
||||||
|
status: input.status ?? "candidate",
|
||||||
|
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
||||||
|
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
};
|
||||||
|
const stored = links.create(link);
|
||||||
|
bus.emit({ type: "EvidenceLinkCreated", linkId: stored.id, link: stored });
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
unlinkEvidence(id) {
|
||||||
|
const removed = links.delete(id);
|
||||||
|
if (removed) {
|
||||||
|
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
},
|
||||||
|
updateLink(id, input) {
|
||||||
|
const existing = links.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`BindingService.updateLink: unknown id ${id}`);
|
||||||
|
}
|
||||||
|
const next: EvidenceLink = {
|
||||||
|
...existing,
|
||||||
|
...(input.status !== undefined ? { status: input.status } : {}),
|
||||||
|
...(input.relation !== undefined ? { relation: input.relation } : {}),
|
||||||
|
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
||||||
|
updatedAt: now(),
|
||||||
|
};
|
||||||
|
const stored = links.update(next);
|
||||||
|
bus.emit({ type: "EvidenceLinkUpdated", linkId: stored.id, link: stored });
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
getLink(id) {
|
||||||
|
return links.get(id);
|
||||||
|
},
|
||||||
|
listEvidenceForTarget(target) {
|
||||||
|
return links.listForTarget(target);
|
||||||
|
},
|
||||||
|
listTargetsForEvidence(evidenceItemId) {
|
||||||
|
return links.listForEvidenceItem(evidenceItemId);
|
||||||
|
},
|
||||||
|
setActiveEvidence(evidenceItemId) {
|
||||||
|
bus.emit({
|
||||||
|
type: "EvidenceItemActivated",
|
||||||
|
evidenceItemId,
|
||||||
|
source: "form-field",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/services/index.ts
Normal file
1
src/services/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./bindings";
|
||||||
64
src/state/active.test.ts
Normal file
64
src/state/active.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Reducer-level tests for the active-state machine.
|
||||||
|
*
|
||||||
|
* React-level Provider/hook tests live with the integration suites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||||
|
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import { __test } from "./active";
|
||||||
|
|
||||||
|
const { reducer, EMPTY_ACTIVE_STATE } = __test;
|
||||||
|
|
||||||
|
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
|
||||||
|
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
|
||||||
|
const EV1 = "ev_one" as EvidenceItemId;
|
||||||
|
const EV2 = "ev_two" as EvidenceItemId;
|
||||||
|
const ANN1 = "ann_one" as AnnotationId;
|
||||||
|
|
||||||
|
describe("ActiveState reducer", () => {
|
||||||
|
it("focus-target sets activeTarget and clears active evidence", () => {
|
||||||
|
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
|
||||||
|
const withEv = reducer(seeded, {
|
||||||
|
type: "set-active-evidence",
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
annotationId: ANN1,
|
||||||
|
});
|
||||||
|
const refocused = reducer(withEv, { type: "focus-target", target: FIELD_B });
|
||||||
|
expect(refocused.activeTarget).toEqual(FIELD_B);
|
||||||
|
expect(refocused.activeEvidenceItemId).toBeNull();
|
||||||
|
expect(refocused.activeAnnotationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focus-target on the same target is a no-op (preserves identity)", () => {
|
||||||
|
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
|
||||||
|
const withEv = reducer(seeded, {
|
||||||
|
type: "set-active-evidence",
|
||||||
|
evidenceItemId: EV1,
|
||||||
|
annotationId: ANN1,
|
||||||
|
});
|
||||||
|
const sameAgain = reducer(withEv, { type: "focus-target", target: { ...FIELD_A } });
|
||||||
|
expect(sameAgain).toBe(withEv);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set-active-evidence updates evidence + annotation without touching target", () => {
|
||||||
|
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
|
||||||
|
const next = reducer(seeded, {
|
||||||
|
type: "set-active-evidence",
|
||||||
|
evidenceItemId: EV2,
|
||||||
|
annotationId: null,
|
||||||
|
});
|
||||||
|
expect(next.activeTarget).toEqual(FIELD_A);
|
||||||
|
expect(next.activeEvidenceItemId).toBe(EV2);
|
||||||
|
expect(next.activeAnnotationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear returns to the empty state", () => {
|
||||||
|
const seeded = reducer(EMPTY_ACTIVE_STATE, { type: "focus-target", target: FIELD_A });
|
||||||
|
const cleared = reducer(seeded, { type: "clear" });
|
||||||
|
expect(cleared).toEqual(EMPTY_ACTIVE_STATE);
|
||||||
|
});
|
||||||
|
});
|
||||||
183
src/state/active.ts
Normal file
183
src/state/active.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* Active state machine + React context for the form-binding flow.
|
||||||
|
*
|
||||||
|
* Tracks the `(activeTarget, activeEvidenceItemId, activeAnnotationId)`
|
||||||
|
* triple that the SVG visual guide and the viewer adapter both depend on.
|
||||||
|
*
|
||||||
|
* Transitions:
|
||||||
|
* - `focusTarget(target)` — clears the active evidence, emits
|
||||||
|
* `FormFieldActivated`.
|
||||||
|
* - `setActiveEvidence(evidenceItemId, annotationId?)` — sets active
|
||||||
|
* evidence (and optionally the active annotation derived from it),
|
||||||
|
* emits `EvidenceItemActivated` with `source="form-field"`. The
|
||||||
|
* binding-service helper does the same; the state machine owns the
|
||||||
|
* React-facing source of truth.
|
||||||
|
* - `clear()` — drops everything back to undefined.
|
||||||
|
*
|
||||||
|
* The state itself is a small immutable record (so React equality checks
|
||||||
|
* stay simple). All mutations go through a single reducer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
createElement,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||||
|
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import type { EventBus } from "@citation-evidence/engine";
|
||||||
|
|
||||||
|
export interface ActiveState {
|
||||||
|
readonly activeTarget: EvidenceTarget | null;
|
||||||
|
readonly activeEvidenceItemId: EvidenceItemId | null;
|
||||||
|
readonly activeAnnotationId: AnnotationId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ACTIVE_STATE: ActiveState = {
|
||||||
|
activeTarget: null,
|
||||||
|
activeEvidenceItemId: null,
|
||||||
|
activeAnnotationId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "focus-target"; target: EvidenceTarget }
|
||||||
|
| {
|
||||||
|
type: "set-active-evidence";
|
||||||
|
evidenceItemId: EvidenceItemId;
|
||||||
|
annotationId: AnnotationId | null;
|
||||||
|
}
|
||||||
|
| { type: "clear-active-evidence" }
|
||||||
|
| { type: "clear" };
|
||||||
|
|
||||||
|
function reducer(state: ActiveState, action: Action): ActiveState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "focus-target":
|
||||||
|
// Focusing a target resets the active evidence — a different field
|
||||||
|
// means a different evidence set.
|
||||||
|
if (
|
||||||
|
state.activeTarget?.targetType === action.target.targetType &&
|
||||||
|
state.activeTarget?.targetId === action.target.targetId
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeTarget: action.target,
|
||||||
|
activeEvidenceItemId: null,
|
||||||
|
activeAnnotationId: null,
|
||||||
|
};
|
||||||
|
case "set-active-evidence":
|
||||||
|
return {
|
||||||
|
activeTarget: state.activeTarget,
|
||||||
|
activeEvidenceItemId: action.evidenceItemId,
|
||||||
|
activeAnnotationId: action.annotationId,
|
||||||
|
};
|
||||||
|
case "clear-active-evidence":
|
||||||
|
return {
|
||||||
|
activeTarget: state.activeTarget,
|
||||||
|
activeEvidenceItemId: null,
|
||||||
|
activeAnnotationId: null,
|
||||||
|
};
|
||||||
|
case "clear":
|
||||||
|
return EMPTY_ACTIVE_STATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveStateApi {
|
||||||
|
readonly state: ActiveState;
|
||||||
|
focusTarget(target: EvidenceTarget): void;
|
||||||
|
setActiveEvidence(
|
||||||
|
evidenceItemId: EvidenceItemId,
|
||||||
|
annotationId?: AnnotationId | null,
|
||||||
|
): void;
|
||||||
|
clearActiveEvidence(): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveStateContext = createContext<ActiveStateApi | null>(null);
|
||||||
|
|
||||||
|
export interface ActiveStateProviderProps {
|
||||||
|
readonly bus: EventBus;
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React provider for the binder's active-state machine. Mounts inside the
|
||||||
|
* EngineProvider so it can wire `bus` from the engine.
|
||||||
|
*/
|
||||||
|
export function ActiveStateProvider(props: ActiveStateProviderProps) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, EMPTY_ACTIVE_STATE);
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const focusTarget = useCallback(
|
||||||
|
(target: EvidenceTarget) => {
|
||||||
|
const previousTarget = stateRef.current.activeTarget;
|
||||||
|
const samePrevious =
|
||||||
|
previousTarget?.targetType === target.targetType &&
|
||||||
|
previousTarget?.targetId === target.targetId;
|
||||||
|
if (samePrevious) return;
|
||||||
|
props.bus.emit({
|
||||||
|
type: "FormFieldActivated",
|
||||||
|
target,
|
||||||
|
...(previousTarget !== null ? { previousTarget } : {}),
|
||||||
|
});
|
||||||
|
dispatch({ type: "focus-target", target });
|
||||||
|
},
|
||||||
|
[props.bus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setActiveEvidence = useCallback(
|
||||||
|
(evidenceItemId: EvidenceItemId, annotationId?: AnnotationId | null) => {
|
||||||
|
props.bus.emit({
|
||||||
|
type: "EvidenceItemActivated",
|
||||||
|
evidenceItemId,
|
||||||
|
source: "form-field",
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "set-active-evidence",
|
||||||
|
evidenceItemId,
|
||||||
|
annotationId: annotationId ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[props.bus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearActiveEvidence = useCallback(() => {
|
||||||
|
dispatch({ type: "clear-active-evidence" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
dispatch({ type: "clear" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ActiveStateApi>(
|
||||||
|
() => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
|
||||||
|
[state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
|
||||||
|
);
|
||||||
|
|
||||||
|
return createElement(ActiveStateContext.Provider, { value }, props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveState(): ActiveStateApi {
|
||||||
|
const ctx = useContext(ActiveStateContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useActiveState must be used inside <ActiveStateProvider />");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure reducer + initial state, exported so the headless tests can verify
|
||||||
|
* transitions without spinning up React.
|
||||||
|
*/
|
||||||
|
export const __test = { reducer, EMPTY_ACTIVE_STATE };
|
||||||
1
src/state/index.ts
Normal file
1
src/state/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./active";
|
||||||
150
src/visual-guide/Overlay.dom.test.tsx
Normal file
150
src/visual-guide/Overlay.dom.test.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
/**
|
||||||
|
* Overlay unit test (CE-WP-0003-T07).
|
||||||
|
*
|
||||||
|
* Verifies the SVG renders the right number of paths given the active
|
||||||
|
* triple state and registered rects. Curve geometry is not asserted —
|
||||||
|
* the bezier helper is intentionally simple and changes will be caught
|
||||||
|
* by visual review, not test maintenance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createEventBus } from "@citation-evidence/engine";
|
||||||
|
|
||||||
|
import { Overlay } from "./Overlay";
|
||||||
|
import { ActiveStateProvider, useActiveState } from "../state/active";
|
||||||
|
import {
|
||||||
|
RectRegistryProvider,
|
||||||
|
createRectRegistryContextValue,
|
||||||
|
type RectRegistryContextValue,
|
||||||
|
} from "./react-hooks";
|
||||||
|
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||||
|
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x, y, width: w, height: h,
|
||||||
|
top: y, left: x, right: x + w, bottom: y + h,
|
||||||
|
toJSON() { return { x, y, width: w, height: h }; },
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
|
||||||
|
const EV_ID = "ev_one" as EvidenceItemId;
|
||||||
|
const ANN_ID = "ann_one" as AnnotationId;
|
||||||
|
|
||||||
|
// Tiny harness to drive the binder's active-state from outside the
|
||||||
|
// provider tree (so the test can stage state without a long click path).
|
||||||
|
function Driver({ onActive }: { onActive: (api: ReturnType<typeof useActiveState>) => void }) {
|
||||||
|
const api = useActiveState();
|
||||||
|
onActive(api);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Overlay (CE-WP-0003-T07)", () => {
|
||||||
|
let ctx: RectRegistryContextValue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ctx = createRectRegistryContextValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ctx.observer.disconnect();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when no triple is active", () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const { container } = render(
|
||||||
|
<RectRegistryProvider value={ctx}>
|
||||||
|
<ActiveStateProvider bus={bus}>
|
||||||
|
<Overlay />
|
||||||
|
</ActiveStateProvider>
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("svg")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws one path when only field + card rects are registered", async () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
let api: ReturnType<typeof useActiveState> | null = null;
|
||||||
|
render(
|
||||||
|
<RectRegistryProvider value={ctx}>
|
||||||
|
<ActiveStateProvider bus={bus}>
|
||||||
|
<Driver onActive={(a) => (api = a)} />
|
||||||
|
<Overlay />
|
||||||
|
</ActiveStateProvider>
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register the two known rects.
|
||||||
|
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
|
||||||
|
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
|
||||||
|
|
||||||
|
// Activate the triple. annotationId left null so no highlight is queried.
|
||||||
|
await act(async () => {
|
||||||
|
api!.focusTarget(FIELD);
|
||||||
|
api!.setActiveEvidence(EV_ID, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
expect(svg.getAttribute("data-path-count")).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws two paths when field + card + highlight rects are all registered", async () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
let api: ReturnType<typeof useActiveState> | null = null;
|
||||||
|
render(
|
||||||
|
<RectRegistryProvider value={ctx}>
|
||||||
|
<ActiveStateProvider bus={bus}>
|
||||||
|
<Driver onActive={(a) => (api = a)} />
|
||||||
|
<Overlay />
|
||||||
|
</ActiveStateProvider>
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
|
||||||
|
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
|
||||||
|
ctx.registry.register("highlight", ANN_ID, () => fakeRect(700, 400, 200, 20));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
api!.focusTarget(FIELD);
|
||||||
|
api!.setActiveEvidence(EV_ID, ANN_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
|
||||||
|
expect(svg.getAttribute("data-path-count")).toBe("2");
|
||||||
|
expect(svg.querySelectorAll("path").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-renders when the registry invalidates after rect changes", async () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
let api: ReturnType<typeof useActiveState> | null = null;
|
||||||
|
render(
|
||||||
|
<RectRegistryProvider value={ctx}>
|
||||||
|
<ActiveStateProvider bus={bus}>
|
||||||
|
<Driver onActive={(a) => (api = a)} />
|
||||||
|
<Overlay />
|
||||||
|
</ActiveStateProvider>
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
ctx.registry.register("field", FIELD.targetId, () => fakeRect(0, 0, 10, 10));
|
||||||
|
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(100, 100, 10, 10));
|
||||||
|
await act(async () => {
|
||||||
|
api!.focusTarget(FIELD);
|
||||||
|
api!.setActiveEvidence(EV_ID, null);
|
||||||
|
});
|
||||||
|
const d1 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
|
||||||
|
// Mutate one of the getters' results, then invalidate.
|
||||||
|
ctx.registry.register("field", FIELD.targetId, () => fakeRect(500, 500, 10, 10));
|
||||||
|
await act(async () => {
|
||||||
|
ctx.registry.invalidate();
|
||||||
|
});
|
||||||
|
const d2 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
|
||||||
|
expect(d1).not.toBe(d2);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
src/visual-guide/Overlay.tsx
Normal file
123
src/visual-guide/Overlay.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* Visual-guide overlay — draws curves between the active triple.
|
||||||
|
*
|
||||||
|
* Subscribes to the rect registry + active-state machine and redraws a
|
||||||
|
* pair of bezier curves on every rect-change event:
|
||||||
|
*
|
||||||
|
* field ──► evidence-card ──► highlight
|
||||||
|
*
|
||||||
|
* Throttling: `attachRectChangePumps` already coalesces scroll/resize
|
||||||
|
* bursts into one `rect-changed` per animation frame. The overlay's
|
||||||
|
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
|
||||||
|
* up that single tick and React re-renders once per frame.
|
||||||
|
*
|
||||||
|
* Active-only: only the currently active triple is drawn. If any leg's
|
||||||
|
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
|
||||||
|
* the active annotation yet), that leg is omitted but the other one
|
||||||
|
* still renders.
|
||||||
|
*
|
||||||
|
* MVP-sufficient. Future polish: easing the curve direction by source
|
||||||
|
* type, animating the transition between active states, dimming
|
||||||
|
* non-active rects rather than hiding them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useActiveState } from "../state/active";
|
||||||
|
import {
|
||||||
|
useRectRegistryContext,
|
||||||
|
useRectRegistryVersion,
|
||||||
|
} from "./react-hooks";
|
||||||
|
|
||||||
|
function rectCenter(rect: DOMRect): { x: number; y: number } {
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.bottom };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.top };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
||||||
|
* horizontally between them. The horizontal-bulge style is right for a
|
||||||
|
* left-pane→centre-pane→right-pane layout; vertical-bulge can be added
|
||||||
|
* later when we have a layout that needs it.
|
||||||
|
*/
|
||||||
|
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const cpx = a.x + dx / 2;
|
||||||
|
return `M ${a.x} ${a.y} Q ${cpx} ${a.y} ${(a.x + b.x) / 2} ${(a.y + b.y) / 2} T ${b.x} ${b.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverlayProps {
|
||||||
|
/** Curve stroke colour. Defaults to the engine's accent blue. */
|
||||||
|
readonly strokeColor?: string;
|
||||||
|
/** Curve stroke width. Defaults to 2px. */
|
||||||
|
readonly strokeWidth?: number;
|
||||||
|
/** Optional className for styling hooks; the inline styles cover layout. */
|
||||||
|
readonly className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Overlay({
|
||||||
|
strokeColor = "#999",
|
||||||
|
strokeWidth = 1,
|
||||||
|
className,
|
||||||
|
}: OverlayProps = {}) {
|
||||||
|
const { state } = useActiveState();
|
||||||
|
const { registry } = useRectRegistryContext();
|
||||||
|
const version = useRectRegistryVersion();
|
||||||
|
|
||||||
|
const paths = useMemo<readonly string[]>(() => {
|
||||||
|
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
|
||||||
|
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
|
||||||
|
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
|
||||||
|
const highlightRect = state.activeAnnotationId
|
||||||
|
? registry.getRect("highlight", state.activeAnnotationId)
|
||||||
|
: null;
|
||||||
|
const out: string[] = [];
|
||||||
|
if (fieldRect && cardRect) {
|
||||||
|
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
|
||||||
|
}
|
||||||
|
if (cardRect && highlightRect) {
|
||||||
|
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
|
||||||
|
}
|
||||||
|
void version; // memo invalidator
|
||||||
|
return out;
|
||||||
|
}, [state, registry, version]);
|
||||||
|
|
||||||
|
if (paths.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-testid="visual-guide-overlay"
|
||||||
|
data-active-target={state.activeTarget?.targetId ?? ""}
|
||||||
|
data-active-evidence={state.activeEvidenceItemId ?? ""}
|
||||||
|
data-path-count={String(paths.length)}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{paths.map((d, i) => (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={d}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/visual-guide/events.ts
Normal file
118
src/visual-guide/events.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* Browser-level rect-change pumps.
|
||||||
|
*
|
||||||
|
* The rect registry holds `getRect` callbacks but doesn't observe the DOM
|
||||||
|
* itself. This module wires the four global change sources from
|
||||||
|
* `wiki/SharedContracts.md` §7 ("scroll, resize, focus, and
|
||||||
|
* active-evidence change") into a single `registry.invalidate()` call.
|
||||||
|
*
|
||||||
|
* Active-evidence change is fired imperatively by the binder service when
|
||||||
|
* it calls `setActiveEvidence` — see `services/bindings.ts`.
|
||||||
|
*
|
||||||
|
* SSR-safe: every API checks `typeof window !== "undefined"` and is a
|
||||||
|
* no-op when the DOM isn't available, so tests that import this module
|
||||||
|
* under Node never crash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RectRegistry } from "./rect-registry";
|
||||||
|
|
||||||
|
export interface RectChangeObserverOptions {
|
||||||
|
/**
|
||||||
|
* Throttle invalidations to a single requestAnimationFrame; otherwise a
|
||||||
|
* fast scroll event burst causes the overlay to redraw on every pixel.
|
||||||
|
* Defaults to true. Tests pass `false` for deterministic synchronous
|
||||||
|
* behaviour.
|
||||||
|
*/
|
||||||
|
readonly throttle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RectChangeObserverHandle {
|
||||||
|
/**
|
||||||
|
* Begin watching a DOM element. The registry is notified of any
|
||||||
|
* scroll/resize/focus event that bubbles to the ancestor chain or fires
|
||||||
|
* on the element itself. Returns a cleanup that stops watching.
|
||||||
|
*/
|
||||||
|
observe(element: Element): () => void;
|
||||||
|
/** Tear down all observers + global listeners. */
|
||||||
|
disconnect(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach scroll/resize/focus pumps to the given registry. Returns an
|
||||||
|
* observer handle so per-element ResizeObservers can be cleaned up by
|
||||||
|
* the components that registered them.
|
||||||
|
*/
|
||||||
|
export function attachRectChangePumps(
|
||||||
|
registry: RectRegistry,
|
||||||
|
options: RectChangeObserverOptions = {},
|
||||||
|
): RectChangeObserverHandle {
|
||||||
|
const throttle = options.throttle ?? true;
|
||||||
|
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return {
|
||||||
|
observe: () => () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = false;
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
if (!throttle) {
|
||||||
|
registry.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending) return;
|
||||||
|
pending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
pending = false;
|
||||||
|
registry.invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = invalidate;
|
||||||
|
const onResize = invalidate;
|
||||||
|
const onFocusIn = invalidate;
|
||||||
|
|
||||||
|
// capture-phase scroll catches scrolling in any nested scroll container,
|
||||||
|
// not just the document — needed for the PDF viewer's inner scroller.
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
||||||
|
window.addEventListener("resize", onResize, { passive: true });
|
||||||
|
document.addEventListener("focusin", onFocusIn);
|
||||||
|
|
||||||
|
// One global ResizeObserver shared across observed elements is cheaper
|
||||||
|
// than per-element observers but loses the per-element resolution; we
|
||||||
|
// don't need per-element resolution because invalidations are global.
|
||||||
|
const ro: ResizeObserver | null =
|
||||||
|
typeof ResizeObserver !== "undefined" ? new ResizeObserver(invalidate) : null;
|
||||||
|
|
||||||
|
// IntersectionObserver fires when an element moves into/out of the
|
||||||
|
// viewport — useful for the highlight which may scroll off-screen.
|
||||||
|
const io: IntersectionObserver | null =
|
||||||
|
typeof IntersectionObserver !== "undefined"
|
||||||
|
? new IntersectionObserver(invalidate, { threshold: [0, 1] })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const observedElements = new Set<Element>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
observe(element) {
|
||||||
|
observedElements.add(element);
|
||||||
|
ro?.observe(element);
|
||||||
|
io?.observe(element);
|
||||||
|
return () => {
|
||||||
|
observedElements.delete(element);
|
||||||
|
ro?.unobserve(element);
|
||||||
|
io?.unobserve(element);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
window.removeEventListener("scroll", onScroll, { capture: true } as EventListenerOptions);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
document.removeEventListener("focusin", onFocusIn);
|
||||||
|
ro?.disconnect();
|
||||||
|
io?.disconnect();
|
||||||
|
observedElements.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/visual-guide/index.ts
Normal file
4
src/visual-guide/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./rect-registry";
|
||||||
|
export * from "./events";
|
||||||
|
export * from "./react-hooks";
|
||||||
|
export { Overlay, type OverlayProps } from "./Overlay";
|
||||||
152
src/visual-guide/react-hooks.dom.test.tsx
Normal file
152
src/visual-guide/react-hooks.dom.test.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* happy-dom-level test for the rect registry React hooks.
|
||||||
|
*
|
||||||
|
* Verifies the full §7 contract under realistic conditions:
|
||||||
|
* - useRegisterRect attaches a getRect callback bound to the
|
||||||
|
* element's getBoundingClientRect
|
||||||
|
* - mutating the element's rect produces fresh values via getRect
|
||||||
|
* - scroll/resize events on window fan out to a registry invalidate
|
||||||
|
* - useRectRegistryVersion bumps each time the registry emits
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RectRegistryProvider,
|
||||||
|
createRectRegistryContextValue,
|
||||||
|
useRectRegistryContext,
|
||||||
|
useRectRegistryVersion,
|
||||||
|
useRegisterRect,
|
||||||
|
} from "./react-hooks";
|
||||||
|
import type { RectRegistryEvent } from "./rect-registry";
|
||||||
|
|
||||||
|
function FieldUnderTest({
|
||||||
|
id,
|
||||||
|
onVersion,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
onVersion?: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useRegisterRect("field", id, ref);
|
||||||
|
const version = useRectRegistryVersion();
|
||||||
|
onVersion?.(version);
|
||||||
|
return <div ref={ref} data-testid={`f-${id}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CtxSpy({ onCtx }: { onCtx: (registry: ReturnType<typeof useRectRegistryContext>) => void }) {
|
||||||
|
const ctx = useRectRegistryContext();
|
||||||
|
onCtx(ctx);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useRegisterRect (happy-dom)", () => {
|
||||||
|
let ctxValue: ReturnType<typeof createRectRegistryContextValue>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ctxValue = createRectRegistryContextValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ctxValue.observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers the element's getBoundingClientRect and unregisters on unmount", () => {
|
||||||
|
const events: RectRegistryEvent[] = [];
|
||||||
|
ctxValue.registry.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<RectRegistryProvider value={ctxValue}>
|
||||||
|
<FieldUnderTest id="summary" />
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ctxValue.registry.getRect("field", "summary")).not.toBeNull();
|
||||||
|
expect(ctxValue.registry.list()).toEqual([{ kind: "field", id: "summary" }]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(ctxValue.registry.getRect("field", "summary")).toBeNull();
|
||||||
|
expect(events.map((e) => e.type)).toContain("unregistered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRect reflects mutated bounding rects", () => {
|
||||||
|
let getter: () => DOMRect | null = () => null;
|
||||||
|
// Spy on the registered callback by hijacking register
|
||||||
|
const realRegister = ctxValue.registry.register;
|
||||||
|
ctxValue.registry.register = (kind, id, fn) => {
|
||||||
|
getter = fn;
|
||||||
|
return realRegister.call(ctxValue.registry, kind, id, fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RectRegistryProvider value={ctxValue}>
|
||||||
|
<FieldUnderTest id="amount" />
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// happy-dom returns a DOMRect with all zeros by default. Patch the
|
||||||
|
// element's getBoundingClientRect and verify the registered callback
|
||||||
|
// forwards the new rect.
|
||||||
|
const el = document.querySelector('[data-testid="f-amount"]') as HTMLDivElement;
|
||||||
|
el.getBoundingClientRect = () => ({
|
||||||
|
x: 11,
|
||||||
|
y: 22,
|
||||||
|
width: 33,
|
||||||
|
height: 44,
|
||||||
|
top: 22,
|
||||||
|
left: 11,
|
||||||
|
right: 11 + 33,
|
||||||
|
bottom: 22 + 44,
|
||||||
|
toJSON() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rect = getter();
|
||||||
|
expect(rect).not.toBeNull();
|
||||||
|
expect(rect!.x).toBe(11);
|
||||||
|
expect(rect!.width).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useRectRegistryVersion bumps on register and on invalidate", async () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const renderResult = render(
|
||||||
|
<RectRegistryProvider value={ctxValue}>
|
||||||
|
<FieldUnderTest
|
||||||
|
id="bumpy"
|
||||||
|
onVersion={(v) => seen.push(v)}
|
||||||
|
/>
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait one microtask for effects to flush.
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
const beforeInvalidate = seen[seen.length - 1]!;
|
||||||
|
await act(async () => {
|
||||||
|
ctxValue.registry.invalidate();
|
||||||
|
});
|
||||||
|
const afterInvalidate = seen[seen.length - 1]!;
|
||||||
|
expect(afterInvalidate).toBeGreaterThan(beforeInvalidate);
|
||||||
|
|
||||||
|
renderResult.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the same registry across consumers in the provider subtree", () => {
|
||||||
|
let firstCtx: ReturnType<typeof useRectRegistryContext> | undefined;
|
||||||
|
let secondCtx: ReturnType<typeof useRectRegistryContext> | undefined;
|
||||||
|
render(
|
||||||
|
<RectRegistryProvider value={ctxValue}>
|
||||||
|
<CtxSpy onCtx={(c) => (firstCtx = c)} />
|
||||||
|
<CtxSpy onCtx={(c) => (secondCtx = c)} />
|
||||||
|
</RectRegistryProvider>,
|
||||||
|
);
|
||||||
|
expect(firstCtx).toBe(secondCtx);
|
||||||
|
expect(firstCtx?.registry).toBe(ctxValue.registry);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/visual-guide/react-hooks.ts
Normal file
98
src/visual-guide/react-hooks.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* React hooks for the rect registry.
|
||||||
|
*
|
||||||
|
* Components mount, get a ref to a DOM node, and ask the registry to
|
||||||
|
* track it via `useRegisterRect(kind, id, ref)`. Unmount/ref-change
|
||||||
|
* unregisters automatically.
|
||||||
|
*
|
||||||
|
* The registry itself lives behind a React context so multiple subtrees
|
||||||
|
* can share one registry (the overlay sees what every renderer publishes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useSyncExternalStore,
|
||||||
|
type RefObject,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRectRegistry,
|
||||||
|
type RectKind,
|
||||||
|
type RectRegistry,
|
||||||
|
} from "./rect-registry";
|
||||||
|
import { attachRectChangePumps, type RectChangeObserverHandle } from "./events";
|
||||||
|
|
||||||
|
export interface RectRegistryContextValue {
|
||||||
|
readonly registry: RectRegistry;
|
||||||
|
readonly observer: RectChangeObserverHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RectRegistryContext = createContext<RectRegistryContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an isolated registry + change pump pair for tests or app
|
||||||
|
* composition roots that wire their own provider.
|
||||||
|
*/
|
||||||
|
export function createRectRegistryContextValue(): RectRegistryContextValue {
|
||||||
|
const registry = createRectRegistry();
|
||||||
|
const observer = attachRectChangePumps(registry);
|
||||||
|
return { registry, observer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRectRegistryContext(): RectRegistryContextValue {
|
||||||
|
const ctx = useContext(RectRegistryContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useRectRegistryContext must be used inside <RectRegistryProvider />",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RectRegistryProvider = RectRegistryContext.Provider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a DOM ref's bounding rect with the registry.
|
||||||
|
*
|
||||||
|
* Re-runs when `kind`/`id`/`ref.current` change. The observer also starts
|
||||||
|
* watching the element for scroll/resize so the overlay can re-query
|
||||||
|
* without polling.
|
||||||
|
*/
|
||||||
|
export function useRegisterRect(
|
||||||
|
kind: RectKind,
|
||||||
|
id: string,
|
||||||
|
ref: RefObject<Element | null>,
|
||||||
|
): void {
|
||||||
|
const { registry, observer } = useRectRegistryContext();
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const unregister = registry.register(kind, id, () => el.getBoundingClientRect());
|
||||||
|
const unobserve = observer.observe(el);
|
||||||
|
return () => {
|
||||||
|
unobserve();
|
||||||
|
unregister();
|
||||||
|
};
|
||||||
|
}, [kind, id, ref, registry, observer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to registry change events from inside React. Returns a
|
||||||
|
* monotonically-increasing version number that bumps on every event, so
|
||||||
|
* `useMemo`/`useEffect` deps can include it to re-derive cached values.
|
||||||
|
*
|
||||||
|
* Implementation: leans on `registry.getVersion()` for the snapshot so
|
||||||
|
* `useSyncExternalStore` doesn't accumulate per-render subscribers.
|
||||||
|
*/
|
||||||
|
export function useRectRegistryVersion(): number {
|
||||||
|
const { registry } = useRectRegistryContext();
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(callback: () => void) => registry.subscribe(callback),
|
||||||
|
[registry],
|
||||||
|
);
|
||||||
|
const getSnapshot = useCallback(() => registry.getVersion(), [registry]);
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, () => 0);
|
||||||
|
}
|
||||||
151
src/visual-guide/rect-registry.test.ts
Normal file
151
src/visual-guide/rect-registry.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Rect registry unit tests — exercise every public surface plus the
|
||||||
|
* §7-contract guarantees:
|
||||||
|
* - register/unregister fire subscriber events
|
||||||
|
* - getRect returns the live result of the registered callback
|
||||||
|
* - invalidate fires a global `rect-changed` event
|
||||||
|
* - version bumps on every emit
|
||||||
|
* - re-registering the same (kind,id) supersedes the prior callback;
|
||||||
|
* the stale unregister cleanup does not delete the new entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRectRegistry,
|
||||||
|
type RectRegistryEvent,
|
||||||
|
} from "./rect-registry";
|
||||||
|
|
||||||
|
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
|
||||||
|
// happy-dom/jsdom isn't loaded for this test — synth a DOMRect-shaped
|
||||||
|
// object. The registry contract only reads these properties.
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
right: x + w,
|
||||||
|
bottom: y + h,
|
||||||
|
toJSON() {
|
||||||
|
return { x, y, width: w, height: h };
|
||||||
|
},
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createRectRegistry", () => {
|
||||||
|
it("returns null for unknown rects", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
expect(r.getRect("field", "missing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("register/getRect roundtrip", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
r.register("field", "f1", () => fakeRect(1, 2, 3, 4));
|
||||||
|
const rect = r.getRect("field", "f1");
|
||||||
|
expect(rect).not.toBeNull();
|
||||||
|
expect(rect!.x).toBe(1);
|
||||||
|
expect(rect!.width).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRect reflects live callback results", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
let xPos = 10;
|
||||||
|
r.register("highlight", "h1", () => fakeRect(xPos, 0, 5, 5));
|
||||||
|
expect(r.getRect("highlight", "h1")!.x).toBe(10);
|
||||||
|
xPos = 200;
|
||||||
|
expect(r.getRect("highlight", "h1")!.x).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the callback throws", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
r.register("field", "boom", () => {
|
||||||
|
throw new Error("nope");
|
||||||
|
});
|
||||||
|
expect(r.getRect("field", "boom")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits registered + unregistered events", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
const events: RectRegistryEvent[] = [];
|
||||||
|
r.subscribe((e) => events.push(e));
|
||||||
|
const unregister = r.register("evidence-card", "ev1", () => fakeRect(0, 0, 1, 1));
|
||||||
|
unregister();
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "registered", kind: "evidence-card", id: "ev1" },
|
||||||
|
{ type: "unregistered", kind: "evidence-card", id: "ev1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidate emits a global rect-changed event and bumps version", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
const events: RectRegistryEvent[] = [];
|
||||||
|
r.subscribe((e) => events.push(e));
|
||||||
|
const before = r.getVersion();
|
||||||
|
r.invalidate();
|
||||||
|
expect(events).toEqual([{ type: "rect-changed" }]);
|
||||||
|
expect(r.getVersion()).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-registering the same (kind,id) supersedes; stale cleanup is a no-op", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
const events: RectRegistryEvent[] = [];
|
||||||
|
r.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
const firstGetRect = () => fakeRect(1, 1, 1, 1);
|
||||||
|
const secondGetRect = () => fakeRect(9, 9, 9, 9);
|
||||||
|
|
||||||
|
const cleanup1 = r.register("highlight", "x", firstGetRect);
|
||||||
|
r.register("highlight", "x", secondGetRect); // supersede
|
||||||
|
|
||||||
|
// The stale cleanup must not remove the new registration.
|
||||||
|
cleanup1();
|
||||||
|
|
||||||
|
expect(r.getRect("highlight", "x")!.x).toBe(9);
|
||||||
|
// Two `registered` events, no `unregistered` event — the second
|
||||||
|
// register overwrote without an explicit unregister, and the stale
|
||||||
|
// cleanup detected the (kind,id) holds a different callback.
|
||||||
|
expect(events.filter((e) => e.type === "unregistered")).toHaveLength(0);
|
||||||
|
expect(events.filter((e) => e.type === "registered")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe returns an unsubscribe that detaches the listener", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
let count = 0;
|
||||||
|
const off = r.subscribe(() => count++);
|
||||||
|
r.invalidate();
|
||||||
|
off();
|
||||||
|
r.invalidate();
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listener errors do not break sibling listeners", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
let okCount = 0;
|
||||||
|
r.subscribe(() => {
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
r.subscribe(() => {
|
||||||
|
okCount++;
|
||||||
|
});
|
||||||
|
r.invalidate();
|
||||||
|
expect(okCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list enumerates current registrations", () => {
|
||||||
|
const r = createRectRegistry();
|
||||||
|
r.register("field", "f1", () => null);
|
||||||
|
r.register("evidence-card", "ev1", () => null);
|
||||||
|
r.register("highlight", "h1", () => null);
|
||||||
|
const list = r.list();
|
||||||
|
expect(list).toHaveLength(3);
|
||||||
|
expect(list).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ kind: "field", id: "f1" },
|
||||||
|
{ kind: "evidence-card", id: "ev1" },
|
||||||
|
{ kind: "highlight", id: "h1" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
src/visual-guide/rect-registry.ts
Normal file
BIN
src/visual-guide/rect-registry.ts
Normal file
Binary file not shown.
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["../citation-engine/src/shared/*"],
|
||||||
|
"@engine/*": ["../citation-engine/src/engine/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "vitest.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
34
vitest.config.ts
Normal file
34
vitest.config.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// The binder splits into a pure headless core (bindings, active-state reducer,
|
||||||
|
// rect-registry) that is node-safe, and a React surface (provider, form
|
||||||
|
// renderer, overlay, registry hooks) exercised in a DOM. Tests whose filename
|
||||||
|
// ends in `.dom.test.{ts,tsx}` run under happy-dom; everything else runs in
|
||||||
|
// Node, which is faster. The react plugin lets the `.tsx` sources compile.
|
||||||
|
//
|
||||||
|
// The one runtime dependency, `@citation-evidence/engine`, resolves through its
|
||||||
|
// package `exports` map (`.` and `./shared`) via the sibling-checkout link. The
|
||||||
|
// engine ships raw TypeScript that uses its own `@shared/*` / `@engine/*` path
|
||||||
|
// aliases internally; tsc honours those via tsconfig `paths`, but Vite does
|
||||||
|
// not read tsconfig, so we mirror them here as resolve aliases. Nothing in this
|
||||||
|
// package imports `citation-evidence` internals — the aliases only exist so the
|
||||||
|
// linked engine source resolves.
|
||||||
|
const enginePath = (rel: string) =>
|
||||||
|
fileURLToPath(new URL(`../citation-engine/src/${rel}`, import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
{ find: /^@shared\//, replacement: `${enginePath("shared")}/` },
|
||||||
|
{ find: /^@engine\//, replacement: `${enginePath("engine")}/` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environmentMatchGlobs: [["**/*.dom.test.{ts,tsx}", "happy-dom"]],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -4,97 +4,149 @@ type: workplan
|
||||||
title: "Extract evidence-binder from citation-evidence"
|
title: "Extract evidence-binder from citation-evidence"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: evidence-binder
|
repo: evidence-binder
|
||||||
status: ready
|
status: done
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: citation_evidence_mvp
|
topic_slug: citation_evidence_mvp
|
||||||
created: "2026-06-21"
|
created: "2026-06-21"
|
||||||
updated: "2026-07-08"
|
updated: "2026-07-09"
|
||||||
state_hub_workstream_id: "405d789c-0e0b-4764-89ed-0f6140a143e7"
|
state_hub_workstream_id: "405d789c-0e0b-4764-89ed-0f6140a143e7"
|
||||||
---
|
---
|
||||||
|
|
||||||
# EBIND-WP-0001 — Extract evidence-binder from citation-evidence
|
# EBIND-WP-0001 — Extract evidence-binder from citation-evidence
|
||||||
|
|
||||||
`evidence-binder` is still INTENT-only in this repo, but the current
|
Extract `../citation-evidence/src/binder/` into this repository as a standalone
|
||||||
implementation already exists upstream in `../citation-evidence/src/binder/`.
|
package without changing the binder's canonical responsibilities: evidence
|
||||||
That slice includes a binding service, in-memory link repository, active-state
|
links, active target/evidence state, and the visual-guide rect registry.
|
||||||
machine, rect registry and overlay, provider wiring, and a small form-oriented
|
|
||||||
reference UI. This workplan turns the placeholder into a concrete extraction
|
|
||||||
plan that preserves the canonical contracts in
|
|
||||||
`../citation-evidence/wiki/SharedContracts.md` and the allowed edges in
|
|
||||||
`../citation-evidence/wiki/DependencyMap.md`.
|
|
||||||
|
|
||||||
## Lock Extraction Boundary And Contract Deltas
|
The extracted package must conform to
|
||||||
|
`../citation-evidence/wiki/SharedContracts.md`, respect
|
||||||
|
`../citation-evidence/wiki/DependencyMap.md`, and remain dependent on
|
||||||
|
`citation-engine` and `evidence-anchor` only.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- umbrella MVP binder slice already exists in `../citation-evidence/src/binder/`
|
||||||
|
- `citation-engine` is already extracted and available as a sibling package
|
||||||
|
- shared contract authority remains in the umbrella docs until binder extraction
|
||||||
|
is complete
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. This repo becomes the canonical home of the binder implementation.
|
||||||
|
2. Umbrella behavior remains unchanged after wireup.
|
||||||
|
3. The extracted package stays target-neutral and does not pick up forbidden
|
||||||
|
`citation-work` or `evidence-source` dependencies.
|
||||||
|
4. Tests, lint, and typecheck are green in both this repo and the umbrella
|
||||||
|
consumer after cutover.
|
||||||
|
|
||||||
|
## Dependency Order
|
||||||
|
|
||||||
|
```text
|
||||||
|
T01 (boundary + contract lock)
|
||||||
|
└─ T02 (toolchain + package scaffold)
|
||||||
|
├─ T03 (headless bindings + provider extraction)
|
||||||
|
│ └─ T04 (visual guide extraction)
|
||||||
|
├─ T05 (reference UI surface decision)
|
||||||
|
└─ T06 (docs + local contract references)
|
||||||
|
└─ T07 (umbrella wireup)
|
||||||
|
└─ T08 (verification + closeout)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T01 — Lock extraction boundary and contract deltas
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T01
|
id: EBIND-WP-0001-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f2abe7f8-089b-4719-a739-18dc3224e9eb"
|
state_hub_task_id: "f2abe7f8-089b-4719-a739-18dc3224e9eb"
|
||||||
```
|
```
|
||||||
|
|
||||||
Review the upstream binder slice (`services/`, `repos/`, `state/`,
|
Review the upstream binder slice against the current shared contracts before any
|
||||||
`visual-guide/`, `BinderProvider.tsx`, `FormRenderer.tsx`) against
|
code moves:
|
||||||
`SharedContracts.md` sections 2.4, 2.5, 4, and 7 plus `DependencyMap.md`
|
|
||||||
section 2. Capture and resolve remaining deltas before extraction, including
|
|
||||||
INTENT drift around `EvidenceTarget` naming, the dropped
|
|
||||||
`derived-from` / `needs-check` relation values, and any event vocabulary that
|
|
||||||
is not yet canonicalized.
|
|
||||||
|
|
||||||
## Stand Up Standalone Package, Tests, And Repo Metadata
|
- `services/`, `repos/`, `state/`, `visual-guide/`
|
||||||
|
- `BinderProvider.tsx`, `FormRenderer.tsx`, `FieldDefinitionForm.tsx`
|
||||||
|
- `../citation-evidence/wiki/SharedContracts.md` sections 2.4, 2.5, 4, and 7
|
||||||
|
- `../citation-evidence/wiki/DependencyMap.md` section 2
|
||||||
|
|
||||||
|
Resolve or explicitly record:
|
||||||
|
|
||||||
|
- INTENT drift around `EvidenceTarget` naming and target vocabulary
|
||||||
|
- dropped relation values `derived-from` / `needs-check`
|
||||||
|
- event vocabulary mismatches such as removal semantics versus the canonical bus
|
||||||
|
- exact files that extract into this repo versus remain umbrella-only
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- extraction inventory is explicit and bounded
|
||||||
|
- no unresolved contract contradiction remains hidden in `INTENT.md`
|
||||||
|
- forbidden dependency edges are called out before code copy starts
|
||||||
|
|
||||||
|
## T02 — Stand up standalone package, tests, and repo metadata
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T02
|
id: EBIND-WP-0001-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "122a254f-6490-4211-bde7-ce515c72c36a"
|
state_hub_task_id: "122a254f-6490-4211-bde7-ce515c72c36a"
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the standalone package and test harness that this repo currently lacks,
|
Create the package scaffold this repo currently lacks:
|
||||||
then refresh repo-local metadata so it stops looking like a placeholder. This
|
|
||||||
includes package entrypoints, test scripts, baseline CI/tooling, `README.md`,
|
|
||||||
`SCOPE.md`, and the stale capability metadata in
|
|
||||||
`registry/indexes/capabilities.yaml`.
|
|
||||||
|
|
||||||
## Extract Headless Binding Model And Repository
|
- `package.json` with `test`, `lint`, and `typecheck` scripts
|
||||||
|
- TypeScript, Vitest, and lint config aligned with sibling extracted repos
|
||||||
|
- entrypoints for binder exports
|
||||||
|
- repo-local `README.md`, `SCOPE.md`, and capability metadata that describe the
|
||||||
|
package as real code rather than INTENT-only placeholder material
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- this repo can install, test, lint, and typecheck as an independent package
|
||||||
|
- top-level docs describe the extracted package surface and repo boundary
|
||||||
|
- `registry/indexes/capabilities.yaml` no longer advertises an empty placeholder
|
||||||
|
|
||||||
|
## T03 — Extract headless bindings, state, and provider composition
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T03
|
id: EBIND-WP-0001-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d58501a4-b2e5-45fa-b504-e2c37ca48e68"
|
state_hub_task_id: "d58501a4-b2e5-45fa-b504-e2c37ca48e68"
|
||||||
```
|
```
|
||||||
|
|
||||||
Move the headless binding primitives from
|
Extract the non-visual binder core:
|
||||||
`../citation-evidence/src/binder/repos/in-memory-links.ts` and
|
|
||||||
`../citation-evidence/src/binder/services/bindings.ts` with their tests. Keep
|
|
||||||
the current query directions, default relation/status behavior, and event-bus
|
|
||||||
emission semantics while staying independent of persistence, viewer internals,
|
|
||||||
`citation-work`, and `evidence-source`.
|
|
||||||
|
|
||||||
## Extract Active State And Provider Composition
|
- `repos/in-memory-links.ts`
|
||||||
|
- `services/bindings.ts` and its tests
|
||||||
|
- `state/active.ts` and its tests
|
||||||
|
- `BinderProvider.tsx`
|
||||||
|
- package exports for the headless service and provider surface
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
- current query directions (`target -> links`, `evidence -> links`)
|
||||||
|
- default relation/status behavior
|
||||||
|
- active target / active evidence / active annotation coordination
|
||||||
|
- bus injection and `initialLinks` restore behavior
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- headless binder behavior runs in this repo with passing tests
|
||||||
|
- no persistence, viewer, `citation-work`, or `evidence-source` dependency is introduced
|
||||||
|
- the binder package can mount its provider and expose the current state API
|
||||||
|
|
||||||
|
## T04 — Extract rect registry, change pumps, and overlay
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T04
|
id: EBIND-WP-0001-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
depends_on: [T03]
|
||||||
state_hub_task_id: "b6f96550-c4a6-4ce1-bfde-4c91d33da687"
|
state_hub_task_id: "b6f96550-c4a6-4ce1-bfde-4c91d33da687"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract `state/active.ts` and `BinderProvider.tsx` so the binder owns the
|
|
||||||
active target / active evidence / active annotation state triple plus the
|
|
||||||
composition root that wires link storage, binding services, rect registry, and
|
|
||||||
engine bus access. Preserve `initialLinks` restore behavior and bus injection
|
|
||||||
without introducing a dependency on umbrella app code.
|
|
||||||
|
|
||||||
## Extract Rect Registry, Change Pumps, And Overlay
|
|
||||||
|
|
||||||
```task
|
|
||||||
id: EBIND-WP-0001-T05
|
|
||||||
status: todo
|
|
||||||
priority: high
|
|
||||||
state_hub_task_id: "58b55a71-eee4-4f3d-9bcd-7c3678320dd1"
|
|
||||||
```
|
|
||||||
|
|
||||||
Move the visual-guide contract from
|
Move the visual-guide contract from
|
||||||
`../citation-evidence/src/binder/visual-guide/`, including
|
`../citation-evidence/src/binder/visual-guide/`, including
|
||||||
`rect-registry.ts`, `events.ts`, `react-hooks.ts`, `Overlay.tsx`, and their
|
`rect-registry.ts`, `events.ts`, `react-hooks.ts`, `Overlay.tsx`, and their
|
||||||
|
|
@ -102,27 +154,68 @@ tests. Preserve the section 7 rect kinds, invalidate/version semantics,
|
||||||
requestAnimationFrame throttling, and the contract that independent renderers
|
requestAnimationFrame throttling, and the contract that independent renderers
|
||||||
publish rects into one registry.
|
publish rects into one registry.
|
||||||
|
|
||||||
## Decide Binder-Owned Reference UI Surface
|
Acceptance:
|
||||||
|
|
||||||
|
- section 7 rect-registry contract is implemented here without behavioral drift
|
||||||
|
- overlay redraw behavior still depends on rect invalidation rather than polling
|
||||||
|
- rect registry, hook, and overlay tests pass from this repo
|
||||||
|
|
||||||
|
## T05 — Decide binder-owned reference UI surface
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: EBIND-WP-0001-T05
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
depends_on: [T03, T04]
|
||||||
|
state_hub_task_id: "58b55a71-eee4-4f3d-9bcd-7c3678320dd1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Make an explicit decision for the small React form surface:
|
||||||
|
|
||||||
|
- keep `FormRenderer.tsx` and `FieldDefinitionForm.tsx` as supported binder exports, or
|
||||||
|
- move them to examples/reference UI and narrow the package surface accordingly
|
||||||
|
|
||||||
|
The decision must preserve the architectural rule that binder is form-friendly
|
||||||
|
but target-neutral and does not depend on `citation-work`.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- the repo documents which React UI pieces are contractual versus illustrative
|
||||||
|
- any retained UI exports compile and test here
|
||||||
|
- any demoted UI files no longer blur the subsystem boundary
|
||||||
|
|
||||||
|
## T06 — Refresh docs and local contract references
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T06
|
id: EBIND-WP-0001-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
depends_on: [T01, T02]
|
||||||
state_hub_task_id: "39164219-5f35-49cf-874a-00fd0c902007"
|
state_hub_task_id: "39164219-5f35-49cf-874a-00fd0c902007"
|
||||||
```
|
```
|
||||||
|
|
||||||
Decide whether `FormRenderer.tsx` and `FieldDefinitionForm.tsx` remain binder
|
Update the repo's local reference material so the extracted package is
|
||||||
exports, move into examples, or shift into an application layer. Whatever the
|
understandable without reopening the umbrella implementation:
|
||||||
outcome, keep binder form-friendly but target-neutral, avoid a `citation-work`
|
|
||||||
dependency, and document which React surfaces are supported versus merely
|
|
||||||
illustrative.
|
|
||||||
|
|
||||||
## Cut Over Umbrella Integration And Verify Boundaries
|
- refresh `README.md` and `SCOPE.md`
|
||||||
|
- update `INTENT.md` where it still implies placeholder-only state
|
||||||
|
- add or copy the minimum local contract references needed for conformance
|
||||||
|
- document the dependency rule: binder may depend on engine and anchor, not on
|
||||||
|
source, work, or umbrella behavior
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- a developer can orient from this repo alone and understand what extracts here
|
||||||
|
- local docs do not contradict the umbrella shared-contract authority
|
||||||
|
- repo metadata no longer looks like a pre-extraction stub
|
||||||
|
|
||||||
|
## T07 — Cut over umbrella integration and verify boundaries
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T07
|
id: EBIND-WP-0001-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
depends_on: [T04, T05, T06]
|
||||||
state_hub_task_id: "efceaee5-43a8-4ecf-9025-a5843eecada0"
|
state_hub_task_id: "efceaee5-43a8-4ecf-9025-a5843eecada0"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -133,17 +226,39 @@ edges to `citation-evidence` behavior, `citation-work`, or `evidence-source`,
|
||||||
and keep shared-contract changes flowing through the umbrella docs until the
|
and keep shared-contract changes flowing through the umbrella docs until the
|
||||||
cutover is complete.
|
cutover is complete.
|
||||||
|
|
||||||
## Exit Criteria
|
Acceptance:
|
||||||
|
|
||||||
|
- umbrella resolves binder imports through this repo rather than a duplicate local copy
|
||||||
|
- binder-specific code is deleted or reduced to package-facing glue in the umbrella repo
|
||||||
|
- dependency-boundary checks still enforce the allowed edge set after wireup
|
||||||
|
|
||||||
|
## T08 — Verification and closeout
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: EBIND-WP-0001-T08
|
id: EBIND-WP-0001-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
depends_on: [T07]
|
||||||
state_hub_task_id: "2c89dfcb-1526-46d9-936d-8f97f4e48d76"
|
state_hub_task_id: "2c89dfcb-1526-46d9-936d-8f97f4e48d76"
|
||||||
```
|
```
|
||||||
|
|
||||||
Exit when this repo exports the binder service, active-state flow, and
|
Verification:
|
||||||
visual-guide rect-registry contract with passing tests; repo docs and metadata
|
|
||||||
match the canonical shared contracts; and the umbrella app can define a target,
|
- `pnpm test` is green in `evidence-binder`
|
||||||
link evidence, activate evidence, and draw the field-to-card-to-highlight
|
- `pnpm typecheck` is green in `evidence-binder`
|
||||||
overlay without relying on the upstream `src/binder/` implementation.
|
- `pnpm lint` is green in `evidence-binder`
|
||||||
|
- umbrella consumer tests/typecheck/lint stay green after wireup
|
||||||
|
- manual smoke still covers the field -> evidence card -> highlight visual guide path
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- this repo exports the binder service, active-state flow, and rect-registry contract
|
||||||
|
- umbrella behavior is unchanged on the MVP evidence-backed form flow
|
||||||
|
- all tasks can be closed individually by a Ralph loop without further task splitting
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- changing canonical enum vocabularies outside the shared-contract process
|
||||||
|
- taking ownership of document ingestion, selector resolution, or viewer internals
|
||||||
|
- making `citation-work` import binder directly
|
||||||
|
- publish/distribution work beyond the extraction and sibling-consumer wireup
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue