- onConfirmAddField?.({
- label: addLabel.trim(),
- type: addType,
- })
- }
- onCancel={() => onCancelAddField?.()}
- saveLabel="Add attribute"
- badge="New attribute"
- testidPrefix="field-add"
- />
- )}
-
{visibleFields.length === 0 && schema.fields.length > 0 && (
onCancelFieldEdit?.()}
/>
))}
+
+ {/* Add control lives below the list so existing attributes stay primary */}
+ {showAddFieldForm ? (
+
+ onConfirmAddField?.({
+ label: addLabel.trim(),
+ type: addType,
+ })
+ }
+ onCancel={() => onCancelAddField?.()}
+ saveLabel="Add attribute"
+ badge="New attribute"
+ testidPrefix="field-add"
+ />
+ ) : (
+
+ )}
);
}
diff --git a/src/index.ts b/src/index.ts
index a0c7754..b1df542 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,5 +9,5 @@ export type {
FormRendererProps,
FormSchema,
} from "./FormRenderer";
-export { BinderProvider, useBinder } from "./BinderProvider";
+export { BinderProvider, useBinder, useBinderOptional } from "./BinderProvider";
export type { BinderServices, BinderProviderProps } from "./BinderProvider";
diff --git a/src/visual-guide/Overlay.tsx b/src/visual-guide/Overlay.tsx
index e0fa2ad..dddb05c 100644
--- a/src/visual-guide/Overlay.tsx
+++ b/src/visual-guide/Overlay.tsx
@@ -1,28 +1,21 @@
/**
- * Visual-guide overlay — draws curves between the active triple.
+ * Visual-guide overlay — draws curves between evidence and attributes.
*
- * Subscribes to the rect registry + active-state machine and redraws a
- * pair of bezier curves on every rect-change event:
+ * Geometry (Capture layout: Evidence column left of Attributes):
+ * evidence-card right edge ──► attribute left edge
*
- * field ──► evidence-card ──► highlight
+ * Relation is n:m:
+ * - Active attribute → lines to every linked evidence card
+ * - Active evidence (no attribute focus) → lines to every linked attribute
+ * - Active evidence → also card → highlight in the viewer
*
- * 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: draws legs for the active evidence card. Attribute→card
- * when a Capture attribute is focused; card→highlight whenever the
- * highlight rect is available (Annotate or Capture). Missing legs are
- * omitted independently.
- *
- * 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.
+ * Throttling: `attachRectChangePumps` coalesces scroll/resize into one
+ * `rect-changed` per animation frame; `useRectRegistryVersion` re-renders.
*/
import { useMemo } from "react";
+import { useBinderOptional } from "../BinderProvider";
import { useActiveState } from "../state/active";
import {
useRectRegistryContext,
@@ -33,11 +26,16 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
+function rectRightCenter(rect: DOMRect): { x: number; y: number } {
+ return { x: rect.right, y: rect.top + rect.height / 2 };
+}
+
+function rectLeftCenter(rect: DOMRect): { x: number; y: number } {
+ return { x: rect.left, y: rect.top + rect.height / 2 };
+}
+
/**
- * 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.
+ * Quadratic bezier with horizontal bulge — suited to side-by-side columns.
*/
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
const dx = b.x - a.x;
@@ -46,11 +44,8 @@ function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): s
}
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;
}
@@ -60,32 +55,52 @@ export function Overlay({
className,
}: OverlayProps = {}) {
const { state } = useActiveState();
+ const binder = useBinderOptional();
const { registry } = useRectRegistryContext();
const version = useRectRegistryVersion();
const paths = useMemo(() => {
- // CE-WP-0010: card → highlight is enough for Annotate mode (no attribute
- // target). Field → card still draws when Capture has an active target.
- if (!state.activeEvidenceItemId) return [];
- const fieldRect = state.activeTarget
- ? registry.getRect("field", state.activeTarget.targetId)
- : null;
- const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
- const highlightRect = state.activeAnnotationId
- ? registry.getRect("highlight", state.activeAnnotationId)
- : null;
const out: string[] = [];
- if (fieldRect && cardRect) {
- // Horizontal layout (Evidence | Attributes): centre-to-centre reads better
- // than the old bottom-strip geometry.
- out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
+ const bindings = binder?.bindings;
+
+ const pushCardField = (evidenceItemId: string, fieldId: string) => {
+ const cardRect = registry.getRect("evidence-card", evidenceItemId);
+ const fieldRect = registry.getRect("field", fieldId);
+ if (!cardRect || !fieldRect) return;
+ // Evidence (left) right edge → attribute (right) left edge
+ out.push(bezierPath(rectRightCenter(cardRect), rectLeftCenter(fieldRect)));
+ };
+
+ if (bindings && state.activeTarget?.targetType === "form-field") {
+ // Attribute selected: all linked evidence cards
+ const links = bindings.listEvidenceForTarget(state.activeTarget);
+ for (const link of links) {
+ pushCardField(link.evidenceItemId, state.activeTarget.targetId);
+ }
+ } else if (bindings && state.activeEvidenceItemId) {
+ // Evidence selected: all linked attributes
+ const links = bindings.listTargetsForEvidence(state.activeEvidenceItemId);
+ for (const link of links) {
+ if (link.targetType !== "form-field") continue;
+ pushCardField(state.activeEvidenceItemId, link.targetId);
+ }
+ } else if (state.activeTarget && state.activeEvidenceItemId) {
+ // Fallback without binder (unit tests): single active pair
+ pushCardField(state.activeEvidenceItemId, state.activeTarget.targetId);
}
- if (cardRect && highlightRect) {
- out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
+
+ // Evidence → citation highlight (Annotate + Capture)
+ if (state.activeEvidenceItemId && state.activeAnnotationId) {
+ const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
+ const highlightRect = registry.getRect("highlight", state.activeAnnotationId);
+ if (cardRect && highlightRect) {
+ out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
+ }
}
- void version; // memo invalidator
+
+ void version;
return out;
- }, [state, registry, version]);
+ }, [state, registry, version, binder]);
if (paths.length === 0) return null;