Files
citation-evidence/src/binder/visual-guide/rect-registry.ts
tegwick 8607c252c4 Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
  - src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
  - src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
  - enum-conformance test parses SharedContracts.md and asserts the
    runtime lists match exactly

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:53:17 +02:00

134 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Rect registry — the contract from `wiki/SharedContracts.md` §7.
*
* Three subsystems publish DOMRects into a single registry so the SVG
* visual-guide overlay can draw curves between them:
*
* - "field" — a form field (registered by FormRenderer)
* - "evidence-card" — a sidebar entry (registered by EvidenceSidebar / similar)
* - "highlight" — a viewer highlight (registered by the PDF adapter)
*
* The overlay subscribes once and queries `getRect` on demand each frame.
* **The contract MUST NOT change after CE-WP-0003-T03 lands** — that's the
* whole point of pinning it now in §7. Future kinds (HTML-doc-section,
* comment-anchor, …) are added by extending the `RectKind` union here AND
* the doc together.
*/
export type RectKind = "field" | "evidence-card" | "highlight";
export interface RectRegistryRegistration {
readonly kind: RectKind;
readonly id: string;
readonly getRect: () => DOMRect | null;
}
/**
* A registry-change signal. Listeners typically don't care which
* specific rect changed — they re-query everything they're tracking —
* but the discriminator is kept so optimised consumers can filter.
*
* `rect-changed` with an undefined `kind`+`id` means "the registry is
* advising you that *something* may have moved" (scroll, resize). That
* is the dominant event in practice.
*/
export type RectRegistryEvent =
| { readonly type: "registered"; readonly kind: RectKind; readonly id: string }
| { readonly type: "unregistered"; readonly kind: RectKind; readonly id: string }
| {
readonly type: "rect-changed";
readonly kind?: RectKind;
readonly id?: string;
};
export interface RectRegistry {
register(
kind: RectKind,
id: string,
getRect: () => DOMRect | null,
): () => void;
getRect(kind: RectKind, id: string): DOMRect | null;
subscribe(listener: (event: RectRegistryEvent) => void): () => void;
/**
* Imperative invalidation. Useful for the overlay's own
* active-evidence-change trigger (which isn't observable via DOM
* observers). Emits a `rect-changed` with no kind/id.
*/
invalidate(): void;
/**
* Monotonically-increasing counter that bumps once per emit. Lets React
* consumers use `useSyncExternalStore` without per-snapshot subscriber
* leaks — the hook only adds one subscriber and reads `getVersion()`
* for the snapshot.
*/
getVersion(): number;
/**
* Debug-only — enumerate everything currently registered. Used in tests
* and not part of the §7 contract; safe to use from `app/`.
*/
list(): readonly { kind: RectKind; id: string }[];
}
function keyOf(kind: RectKind, id: string): string {
return `${kind}${id}`;
}
export function createRectRegistry(): RectRegistry {
const entries = new Map<string, RectRegistryRegistration>();
const listeners = new Set<(event: RectRegistryEvent) => void>();
let version = 0;
function emit(event: RectRegistryEvent) {
version++;
for (const l of listeners) {
try {
l(event);
} catch {
// Listener errors are swallowed: the overlay never wants a
// single broken consumer to crash the others.
}
}
}
return {
register(kind, id, getRect) {
const key = keyOf(kind, id);
entries.set(key, { kind, id, getRect });
emit({ type: "registered", kind, id });
return () => {
const current = entries.get(key);
// Only unregister if it's *this* registration. A re-register with
// the same (kind,id) before our cleanup runs supersedes us.
if (current && current.getRect === getRect) {
entries.delete(key);
emit({ type: "unregistered", kind, id });
}
};
},
getRect(kind, id) {
const entry = entries.get(keyOf(kind, id));
if (!entry) return null;
try {
return entry.getRect();
} catch {
return null;
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
invalidate() {
emit({ type: "rect-changed" });
},
getVersion() {
return version;
},
list() {
return [...entries.values()].map((e) => ({ kind: e.kind, id: e.id }));
},
};
}