diff --git a/src/binder/BinderProvider.tsx b/src/binder/BinderProvider.tsx deleted file mode 100644 index 668f411..0000000 --- a/src/binder/BinderProvider.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * BinderProvider — composition root for the binder subsystem. - * - * Wires the four binder concerns (rect registry, binding service, link - * repo, active state machine) into one provider so a single mount inside - * the EngineProvider gives every binder consumer (FormRenderer, evidence - * picker, SVG overlay) what it needs. - * - * The provider is split out from the engine because in a future - * subsystem-extraction these will live in separate packages — the engine - * will publish only the event bus and the engine services, while - * `evidence-binder` will export this provider. - */ - -import { - createContext, - useContext, - useEffect, - useMemo, - type ReactNode, -} from "react"; - -import type { EvidenceLink } from "@shared/evidence-link"; - -import type { EventBus } from "@engine/events"; - -import { - ActiveStateProvider, - useActiveState, -} from "./state/active"; -import { - createInMemoryLinkRepo, - type EvidenceLinkRepository, -} from "./repos/in-memory-links"; -import { - createBindingService, - type BindingService, -} from "./services/bindings"; -import { - RectRegistryProvider, - createRectRegistryContextValue, - type RectRegistryContextValue, -} from "./visual-guide/react-hooks"; - -export interface BinderServices { - readonly links: EvidenceLinkRepository; - readonly bindings: BindingService; - readonly rect: RectRegistryContextValue; -} - -const BinderServicesContext = createContext(null); - -export function useBinder(): BinderServices { - const ctx = useContext(BinderServicesContext); - if (!ctx) throw new Error("useBinder: missing "); - return ctx; -} - -export interface BinderProviderProps { - readonly children: ReactNode; - /** - * The engine's event bus, threaded in by the composition root so the - * binder can emit §4 events without importing work/EngineContext - * (work cannot be a dependency of binder — see DependencyMap §2). - */ - readonly bus: EventBus; - /** - * Tests can inject a pre-built service set; production constructs a - * fresh one. The rect registry is *always* fresh per provider mount - * because its observers attach to the current `window`. - */ - readonly services?: Omit; - /** - * Restored evidence links for this session. Seeded directly into the - * repo (no bus events) so reload does not spuriously re-emit - * `EvidenceLinkCreated`. - */ - readonly initialLinks?: readonly EvidenceLink[]; -} - -export function BinderProvider({ - children, - bus, - services, - initialLinks, -}: BinderProviderProps) { - const built = useMemo(() => { - const links = services?.links ?? createInMemoryLinkRepo(); - const bindings = services?.bindings ?? createBindingService(links, bus); - const rect = createRectRegistryContextValue(); - return { links, bindings, rect }; - }, [bus, services]); - - useEffect(() => { - if (!initialLinks?.length || services?.links) return; - for (const link of initialLinks) { - if (!built.links.get(link.id)) { - built.links.create(link); - } - } - }, [built.links, initialLinks, services?.links]); - - // Disconnect rect observers + listeners on unmount. - useEffect(() => { - return () => { - built.rect.observer.disconnect(); - }; - }, [built.rect]); - - return ( - - - {children} - - - ); -} - -export { useActiveState }; diff --git a/src/binder/FieldDefinitionForm.tsx b/src/binder/FieldDefinitionForm.tsx deleted file mode 100644 index cbedb39..0000000 --- a/src/binder/FieldDefinitionForm.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11). - * Styled to match EvidenceFormBody / InlineCaptureForm. - */ - -import type { CSSProperties, ReactNode } from "react"; - -import type { FormFieldSchema } from "./FormRenderer"; - -export type FieldType = FormFieldSchema["type"]; - -const FIELD_TYPES: readonly { value: FieldType; label: string }[] = [ - { value: "text", label: "Text" }, - { value: "textarea", label: "Text area" }, - { value: "date", label: "Date" }, -]; - -export interface FieldDefinitionFormProps { - readonly label: string; - readonly type: FieldType; - onChangeLabel(next: string): void; - onChangeType(next: FieldType): void; - onSave(): void; - onCancel(): void; - readonly saveLabel?: string; - readonly cancelLabel?: string; - readonly badge?: ReactNode; - readonly testidPrefix: string; -} - -export function FieldDefinitionForm(p: FieldDefinitionFormProps) { - const saveLabel = p.saveLabel ?? "Save"; - const cancelLabel = p.cancelLabel ?? "Cancel"; - - return ( -
- {p.badge && ( -
{p.badge}
- )} - - p.onChangeLabel(e.target.value)} - data-testid={`${p.testidPrefix}-label-input`} - style={inputStyle} - /> - - -
- - -
-
- ); -} - -const labelStyle: CSSProperties = { - display: "block", - color: "#666", - fontSize: 11, - marginBottom: 2, -}; - -const inputStyle: CSSProperties = { - width: "100%", - boxSizing: "border-box", - fontSize: 12, - padding: 4, - marginBottom: 6, -}; - -const buttonStyle: CSSProperties = { - fontSize: 12, - padding: "4px 10px", -}; \ No newline at end of file diff --git a/src/binder/FormRenderer.dom.test.tsx b/src/binder/FormRenderer.dom.test.tsx deleted file mode 100644 index a4da6fe..0000000 --- a/src/binder/FormRenderer.dom.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * FormRenderer (CE-WP-0003-T04) — happy-dom test covering: - * - schema → DOM (3 demo fields render with their labels) - * - each field registers with rect registry as kind="field" - * - focusing a field calls activeState.focusTarget and emits FormFieldActivated - * - typing in a field invokes onValueChange - * - linkCounts shows the chip when > 0 - */ - -// @vitest-environment happy-dom - -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { createEventBus, type EngineEvent } from "@engine/events"; - -import { FormRenderer, type FormSchema } from "./FormRenderer"; -import { - ActiveStateProvider, -} from "./state/active"; -import { - RectRegistryProvider, - createRectRegistryContextValue, -} from "./visual-guide/react-hooks"; - -const SCHEMA: FormSchema = { - id: "demo", - title: "Demo form", - fields: [ - { type: "textarea", id: "summary", label: "Summary" }, - { type: "date", id: "deadline", label: "Deadline" }, - { type: "text", id: "amount", label: "Amount" }, - ], -}; - -function renderWithProviders(props: Parameters[0]) { - const bus = createEventBus(); - const events: EngineEvent[] = []; - bus.onAny((e) => events.push(e)); - const ctxValue = createRectRegistryContextValue(); - const utils = render( - - - - - , - ); - return { ...utils, ctxValue, bus, events }; -} - -describe("FormRenderer (CE-WP-0003-T04)", () => { - let cleanupCtx: (() => void) | null = null; - beforeEach(() => { - cleanupCtx = null; - }); - afterEach(() => { - cleanupCtx?.(); - cleanup(); - }); - - it("renders each schema field with its label", () => { - renderWithProviders({ schema: SCHEMA }); - expect(screen.getByLabelText("Summary")).toBeTruthy(); - expect(screen.getByLabelText("Deadline")).toBeTruthy(); - expect(screen.getByLabelText("Amount")).toBeTruthy(); - }); - - it("registers each field with the rect registry as kind=field", () => { - const { ctxValue } = renderWithProviders({ schema: SCHEMA }); - cleanupCtx = () => ctxValue.observer.disconnect(); - const list = ctxValue.registry.list(); - expect(list).toHaveLength(3); - expect(list.every((r) => r.kind === "field")).toBe(true); - expect(list.map((r) => r.id).sort()).toEqual(["amount", "deadline", "summary"]); - }); - - it("focusing a field emits FormFieldActivated with the right target", async () => { - const user = userEvent.setup(); - const { events, ctxValue } = renderWithProviders({ schema: SCHEMA }); - cleanupCtx = () => ctxValue.observer.disconnect(); - await user.click(screen.getByLabelText("Summary")); - const fieldEvents = events.filter((e) => e.type === "FormFieldActivated"); - expect(fieldEvents).toHaveLength(1); - expect(fieldEvents[0]).toMatchObject({ - target: { targetType: "form-field", targetId: "summary" }, - }); - }); - - it("typing forwards onValueChange with the field id + new value", async () => { - const user = userEvent.setup(); - const changes: [string, string][] = []; - const { ctxValue } = renderWithProviders({ - schema: SCHEMA, - onValueChange: (id, value) => changes.push([id, value]), - }); - cleanupCtx = () => ctxValue.observer.disconnect(); - await user.type(screen.getByLabelText("Amount"), "42"); - expect(changes).toEqual([ - ["amount", "4"], - ["amount", "2"], - ]); - }); - - it("renders the link-count chip when linkCounts[fieldId] > 0", () => { - const { ctxValue } = renderWithProviders({ - schema: SCHEMA, - linkCounts: { summary: 2, amount: 0 }, - }); - cleanupCtx = () => ctxValue.observer.disconnect(); - expect(screen.queryByTestId("field-summary-chip")).not.toBeNull(); - expect(screen.queryByTestId("field-amount-chip")).toBeNull(); - }); -}); diff --git a/src/binder/FormRenderer.tsx b/src/binder/FormRenderer.tsx deleted file mode 100644 index b4b561a..0000000 --- a/src/binder/FormRenderer.tsx +++ /dev/null @@ -1,324 +0,0 @@ -/** - * 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 "@shared/evidence-link"; - -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>; - readonly onValueChange?: (fieldId: string, value: string) => void; - readonly linkCounts?: Readonly>; - readonly linkHints?: Readonly>; - 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(null); - useRegisterRect("field", field.id, ref); - - if (isEditing) { - return ( -
- -
- ); - } - - const sharedProps = { - id: `field-${field.id}`, - value, - onFocus, - onChange: (e: ChangeEvent) => - onChange(e.target.value), - style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 }, - }; - - return ( -
- - - {field.type === "textarea" ? ( -