citation-evidence/src/binder/state/active.ts

184 lines
5.3 KiB
TypeScript
Raw Normal View History

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
/**
* Active state machine + React context for the form-binding flow.
*
* Tracks the `(activeTarget, activeEvidenceItemId, activeAnnotationId)`
* triple that the SVG visual guide and the viewer adapter both depend on.
*
* Transitions:
* - `focusTarget(target)` clears the active evidence, emits
* `FormFieldActivated`.
* - `setActiveEvidence(evidenceItemId, annotationId?)` sets active
* evidence (and optionally the active annotation derived from it),
* emits `EvidenceItemActivated` with `source="form-field"`. The
* binding-service helper does the same; the state machine owns the
* React-facing source of truth.
* - `clear()` drops everything back to undefined.
*
* The state itself is a small immutable record (so React equality checks
* stay simple). All mutations go through a single reducer.
*/
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
type ReactNode,
} from "react";
import type { EvidenceTarget } from "@shared/evidence-link";
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
import type { EventBus } from "@engine/events";
export interface ActiveState {
readonly activeTarget: EvidenceTarget | null;
readonly activeEvidenceItemId: EvidenceItemId | null;
readonly activeAnnotationId: AnnotationId | null;
}
export const EMPTY_ACTIVE_STATE: ActiveState = {
activeTarget: null,
activeEvidenceItemId: null,
activeAnnotationId: null,
};
type Action =
| { type: "focus-target"; target: EvidenceTarget }
| {
type: "set-active-evidence";
evidenceItemId: EvidenceItemId;
annotationId: AnnotationId | null;
}
| { type: "clear-active-evidence" }
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
| { 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,
};
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
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;
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
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" });
}, []);
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
const clear = useCallback(() => {
dispatch({ type: "clear" });
}, []);
const value = useMemo<ActiveStateApi>(
() => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
[state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
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
);
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 };