n:m attribute–evidence guide lines from card right to field left.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Overlay draws all links for the selected attribute or evidence; optional
binder context preserves unit tests without BinderProvider.
This commit is contained in:
tegwick 2026-07-30 20:31:49 +02:00
parent 32c5a2ede4
commit 9dc4880ec1
4 changed files with 105 additions and 83 deletions

View file

@ -56,6 +56,11 @@ export function useBinder(): BinderServices {
return ctx;
}
/** Null when outside BinderProvider (e.g. Overlay unit tests). */
export function useBinderOptional(): BinderServices | null {
return useContext(BinderServicesContext);
}
export interface BinderProviderProps {
readonly children: ReactNode;
/**

View file

@ -277,7 +277,6 @@ export function FormRenderer({
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 8,
flexWrap: "wrap",
@ -303,53 +302,14 @@ export function FormRenderer({
style={{
flex: "1 1 100px",
minWidth: 80,
maxWidth: 160,
fontSize: 12,
padding: "4px 6px",
border: "1px solid #ccc",
borderRadius: 4,
}}
/>
<button
type="button"
data-testid="add-field-button"
onClick={() => {
setAddLabel(`New attribute ${schema.fields.length + 1}`);
setAddType("text");
onRequestAddField?.();
}}
style={{
fontSize: 11,
padding: "4px 10px",
border: "1px solid #888",
borderRadius: 4,
background: "white",
cursor: "pointer",
}}
>
Add attribute
</button>
</div>
{showAddFieldForm && (
<FieldDefinitionForm
label={addLabel}
type={addType}
onChangeLabel={setAddLabel}
onChangeType={setAddType}
onSave={() =>
onConfirmAddField?.({
label: addLabel.trim(),
type: addType,
})
}
onCancel={() => onCancelAddField?.()}
saveLabel="Add attribute"
badge="New attribute"
testidPrefix="field-add"
/>
)}
{visibleFields.length === 0 && schema.fields.length > 0 && (
<p
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
@ -386,6 +346,48 @@ export function FormRenderer({
onCancelEdit={() => onCancelFieldEdit?.()}
/>
))}
{/* Add control lives below the list so existing attributes stay primary */}
{showAddFieldForm ? (
<FieldDefinitionForm
label={addLabel}
type={addType}
onChangeLabel={setAddLabel}
onChangeType={setAddType}
onSave={() =>
onConfirmAddField?.({
label: addLabel.trim(),
type: addType,
})
}
onCancel={() => onCancelAddField?.()}
saveLabel="Add attribute"
badge="New attribute"
testidPrefix="field-add"
/>
) : (
<button
type="button"
data-testid="add-field-button"
onClick={() => {
setAddLabel(`New attribute ${schema.fields.length + 1}`);
setAddType("text");
onRequestAddField?.();
}}
style={{
fontSize: 11,
padding: "6px 10px",
border: "1px dashed #888",
borderRadius: 4,
background: "white",
cursor: "pointer",
width: "100%",
marginTop: 4,
}}
>
Add attribute
</button>
)}
</div>
);
}

View file

@ -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";

View file

@ -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. Attributecard
* when a Capture attribute is focused; cardhighlight 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-panecentre-paneright-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<readonly string[]>(() => {
// 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;