Extract binder package from citation-evidence (EBIND-WP-0001)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

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:
tegwick 2026-07-09 01:36:33 +02:00
parent 87d4eb3006
commit a10f080f12
36 changed files with 6643 additions and 170 deletions

324
src/FormRenderer.tsx Normal file
View 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>
);
}