diff --git a/docs/mvp-workplans-index.md b/docs/mvp-workplans-index.md index 0141851..6d8d4e3 100644 --- a/docs/mvp-workplans-index.md +++ b/docs/mvp-workplans-index.md @@ -31,7 +31,7 @@ publish tasks wait on ADR-0002 resolution. | Workplan | Title | Status | |----------|-------|--------| -| `CE-WP-0010` | Annotate & Attributes UX — labels, filters, layout, evidence connectors | proposed | +| `CE-WP-0010` | Annotate & Attributes UX — labels, filters, layout, evidence connectors | finished | User-facing polish after manual document review: rename Review→Annotate, keep Evidence column in Capture with Attributes on the right, list filters, and diff --git a/src/app/App.tsx b/src/app/App.tsx index 0873466..50fcee3 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -316,7 +316,7 @@ function ActiveTopBar({ const tabs = useMemo( () => [ - { id: "review" as const, label: "Review" }, + { id: "review" as const, label: "Annotate" }, { id: "forms" as const, label: "Capture" }, ], [], diff --git a/src/app/ReviewLayout.tsx b/src/app/ReviewLayout.tsx index 8cb94fa..6741c05 100644 --- a/src/app/ReviewLayout.tsx +++ b/src/app/ReviewLayout.tsx @@ -1,18 +1,19 @@ /** - * Review mode — the three-pane layout from CE-WP-0002-T06. + * Annotate mode — three-pane layout (CE-WP-0002-T06; CE-WP-0010 rename). * * ┌────────────┬──────────────────┬────────────┐ * │ Collection │ Document Viewer │ Evidence │ - * │ List │ │ Sidebar │ * └────────────┴──────────────────┴────────────┘ * - * CE-WP-0005 added an `upload` slot for the active session's upload - * dropzone, threaded in by the app composition root so this component - * stays inside the `work` boundary (which cannot import `app`). + * CE-WP-0005: `upload` slot for the active session's dropzone. + * CE-WP-0010: evidence → citation connector via Overlay + card/highlight bridges. */ -import type { ReactNode } from "react"; +import { useCallback, type ReactNode } from "react"; +import type { EvidenceItem } from "@shared/evidence"; + +import { Overlay, useActiveState } from "@binder/index"; import { CollectionList, EvidenceSidebar, @@ -20,23 +21,41 @@ import { useActiveSession, } from "@work/index"; +import { EvidenceCardRectBridge } from "./forms/EvidenceCardRectBridge"; +import { HighlightRectBridge } from "./forms/HighlightRectBridge"; +import { ScrollBridge } from "./forms/ScrollBridge"; + export interface ReviewLayoutProps { readonly upload?: ReactNode; } export function ReviewLayout({ upload }: ReviewLayoutProps) { const session = useActiveSession(); + const { setActiveEvidence } = useActiveState(); + + const handleActivate = useCallback( + (item: EvidenceItem) => { + setActiveEvidence(item.id, item.annotationIds[0] ?? null); + }, + [setActiveEvidence], + ); + return (
- + + + + +
); } diff --git a/src/app/forms/EvidenceCardRectBridge.tsx b/src/app/forms/EvidenceCardRectBridge.tsx new file mode 100644 index 0000000..cd6f702 --- /dev/null +++ b/src/app/forms/EvidenceCardRectBridge.tsx @@ -0,0 +1,54 @@ +/** + * EvidenceCardRectBridge — registers evidence-card rects for the visual guide + * without forcing citation-work to depend on evidence-binder (DependencyMap: + * work ⊄ binder). + * + * Looks up live DOM nodes via `data-testid="evidence-card-"` on the + * EvidenceSidebar cards and publishes them to the rect registry as + * `kind="evidence-card"`. + */ + +import { useEffect, useMemo } from "react"; + +import { useRectRegistryContext } from "@binder/index"; +import { + useActiveDocument, + useEngine, + useEngineEventTick, + useEngineRevision, +} from "@work/index"; + +export function EvidenceCardRectBridge() { + const engine = useEngine(); + const { document } = useActiveDocument(); + const { registry } = useRectRegistryContext(); + const createTick = useEngineEventTick("EvidenceItemCreated"); + const updateTick = useEngineEventTick("EvidenceItemUpdated"); + const revision = useEngineRevision(); + + const itemIds = useMemo(() => { + if (!document) return [] as readonly string[]; + void createTick; + void updateTick; + void revision; + return engine.evidence.listByDocument(document.id).map((i) => i.id); + }, [document, engine, createTick, updateTick, revision]); + + useEffect(() => { + const unregisters = itemIds.map((id) => + registry.register("evidence-card", id, () => { + if (typeof globalThis.document === "undefined") return null; + const el = globalThis.document.querySelector( + `[data-testid="evidence-card-${id}"]`, + ); + if (!el) return null; + return el.getBoundingClientRect(); + }), + ); + return () => { + for (const unregister of unregisters) unregister(); + }; + }, [itemIds, registry]); + + return null; +} diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx index 0157ace..d9857c0 100644 --- a/src/app/forms/FormsApp.tsx +++ b/src/app/forms/FormsApp.tsx @@ -1,55 +1,43 @@ /** - * FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007). + * FormsApp (Capture mode) — evidence-backed attributes layout (CE-WP-0010). * - * Layout (CE-WP-0007): + * Layout: * - * ┌────────────┬─────────────────┬─────────────┐ - * │ Collection │ ViewerShell │ FormPane │ - * ├────────────┴─────────────────┴─────────────┤ - * │ EvidenceStrip (bottom) │ - * └────────────────────────────────────────────┘ + * ┌────────────┬─────────────────┬──────────┬────────────┐ + * │ Collection │ ViewerShell │ Evidence │ Attributes │ + * └────────────┴─────────────────┴──────────┴────────────┘ * - * Linking: field must have focus; clicking evidence links directly. + * Linking: attribute must have focus; clicking an evidence card links + * directly (CE-WP-0007 focus-gated linking). No bottom evidence strip. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { EvidenceItem } from "@shared/evidence"; -import type { EvidenceLink } from "@shared/evidence-link"; -import type { EvidenceItemId } from "@shared/ids"; +import type { EvidenceItemId, SessionId } from "@shared/ids"; import { Overlay, useActiveState, useBinder, - useRegisterRect, } from "@binder/index"; -import type { SessionId } from "@shared/ids"; - import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer"; import { CollectionList, + EvidenceSidebar, ViewerShell, useActiveDocument, useEngine, useEngineEventTick, - useScrollToAnnotation, } from "@work/index"; import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer"; +import { EvidenceCardRectBridge } from "./EvidenceCardRectBridge"; +import { HighlightRectBridge } from "./HighlightRectBridge"; +import { ScrollBridge } from "./ScrollBridge"; import { persistCapturePatch } from "./capture-persistence"; import { DEMO_SCHEMA } from "./demo-schema"; -import { HighlightRectBridge } from "./HighlightRectBridge"; - -export type EvidenceStripFilter = "all" | "attached"; - -const STRIP_FILTER_EVENT = "citation-evidence:strip-filter"; - -function publishStripFilter(mode: EvidenceStripFilter) { - if (typeof window === "undefined") return; - window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode })); -} function quotePreview(text: string, max = 80): string { const t = text.trim(); @@ -73,11 +61,6 @@ export function FormsApp({ : { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] }, ); - const fieldLabels = useMemo( - () => new Map(schema.fields.map((f) => [f.id, f.label] as const)), - [schema], - ); - const [fieldValues, setFieldValues] = useState>( () => ({ ...(initialFieldValues ?? {}) }), ); @@ -109,7 +92,7 @@ export function FormsApp({ const field: FormFieldSchema = { id, type: patch.type, - label: patch.label.length > 0 ? patch.label : `New field ${n}`, + label: patch.label.length > 0 ? patch.label : `New attribute ${n}`, }; return { ...prev, fields: [...prev.fields, field] }; }); @@ -138,50 +121,77 @@ export function FormsApp({ ); return ( -
-
- - - { - setEditingFieldId(null); - setShowAddFieldForm(true); - }} - onConfirmAddField={handleConfirmAddField} - onCancelAddField={() => setShowAddFieldForm(false)} - onBeginEditField={(fieldId) => { - setShowAddFieldForm(false); - setEditingFieldId(fieldId); - }} - onSaveFieldEdit={handleSaveFieldEdit} - onCancelFieldEdit={() => setEditingFieldId(null)} - /> -
- +
+ + + + { + setEditingFieldId(null); + setShowAddFieldForm(true); + }} + onConfirmAddField={handleConfirmAddField} + onCancelAddField={() => setShowAddFieldForm(false)} + onBeginEditField={(fieldId) => { + setShowAddFieldForm(false); + setEditingFieldId(fieldId); + }} + onSaveFieldEdit={handleSaveFieldEdit} + onCancelFieldEdit={() => setEditingFieldId(null)} + /> +
); } -function ScrollBridge() { - const { state } = useActiveState(); - const { scrollTo } = useScrollToAnnotation(); - useEffect(() => { - if (state.activeAnnotationId) { - scrollTo(state.activeAnnotationId); - } - }, [state.activeAnnotationId, scrollTo]); - return null; +/** + * Evidence sidebar wired for Capture: activate sets binder active evidence + * and links to the focused attribute when present. + */ +function CaptureEvidenceColumn() { + const { state: activeState, setActiveEvidence } = useActiveState(); + const { bindings } = useBinder(); + + const tryLink = useCallback( + (evidenceItemId: EvidenceItemId, fieldId: string): boolean => { + const existing = bindings + .listEvidenceForTarget({ targetType: "form-field", targetId: fieldId }) + .some((l) => l.evidenceItemId === evidenceItemId); + if (existing) return false; + bindings.linkEvidenceToTarget({ + evidenceItemId, + target: { targetType: "form-field", targetId: fieldId }, + }); + return true; + }, + [bindings], + ); + + const handleActivate = useCallback( + (item: EvidenceItem) => { + const annId = item.annotationIds[0] ?? null; + setActiveEvidence(item.id, annId); + + const target = activeState.activeTarget; + if (target?.targetType === "form-field") { + tryLink(item.id, target.targetId); + } + }, + [activeState.activeTarget, setActiveEvidence, tryLink], + ); + + return ; } -function FormPane({ +function AttributesPane({ schema, fieldValues, onFieldValueChange, @@ -213,12 +223,6 @@ function FormPane({ const unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); const { state: activeState, setActiveEvidence } = useActiveState(); - useEffect(() => { - return engine.bus.on("FormFieldActivated", () => { - publishStripFilter("attached"); - }); - }, [engine]); - useEffect(() => { const target = activeState.activeTarget; if (!target || activeState.activeEvidenceItemId) return; @@ -272,9 +276,10 @@ function FormPane({ return (
- Pick a document from the collection to start capturing evidence links. + Pick a document from the collection to start linking evidence to attributes.

); } - -function EvidenceStrip({ - fieldLabels, -}: { - fieldLabels: ReadonlyMap; -}) { - const engine = useEngine(); - const { bindings } = useBinder(); - const { document } = useActiveDocument(); - const createTick = useEngineEventTick("EvidenceItemCreated"); - const updateTick = useEngineEventTick("EvidenceItemUpdated"); - const linkTick = useEngineEventTick("EvidenceLinkCreated"); - const unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); - const { state: activeState, setActiveEvidence, clearActiveEvidence } = - useActiveState(); - - const [userFilter, setUserFilter] = useState("all"); - const [sessionFilter, setSessionFilter] = useState( - null, - ); - - const effectiveFilter = sessionFilter ?? userFilter; - - useEffect(() => { - const handler = (e: Event) => { - setSessionFilter((e as CustomEvent).detail); - }; - window.addEventListener(STRIP_FILTER_EVENT, handler); - return () => window.removeEventListener(STRIP_FILTER_EVENT, handler); - }, []); - - useEffect(() => { - if (!activeState.activeTarget) { - setSessionFilter(null); - } - }, [activeState.activeTarget]); - - const allItems = useMemo(() => { - if (!document) return []; - void createTick; - void updateTick; - void linkTick; - void unlinkTick; - return engine.evidence.listByDocument(document.id); - }, [document, engine, createTick, updateTick, linkTick, unlinkTick]); - - const items = useMemo(() => { - if (effectiveFilter !== "attached" || !activeState.activeTarget) { - return allItems; - } - const links = bindings.listEvidenceForTarget(activeState.activeTarget); - const ids = new Set(links.map((l) => l.evidenceItemId)); - const attached = allItems.filter((item) => ids.has(item.id)); - return attached.length > 0 ? attached : allItems; - }, [ - allItems, - effectiveFilter, - activeState.activeTarget, - bindings, - linkTick, - unlinkTick, - ]); - - const tryLink = useCallback( - (evidenceItemId: EvidenceItemId, fieldId: string): boolean => { - const existing = bindings - .listEvidenceForTarget({ targetType: "form-field", targetId: fieldId }) - .some((l) => l.evidenceItemId === evidenceItemId); - if (existing) return false; - bindings.linkEvidenceToTarget({ - evidenceItemId, - target: { targetType: "form-field", targetId: fieldId }, - }); - return true; - }, - [bindings], - ); - - const handleCardClick = useCallback( - (item: EvidenceItem) => { - const annId = item.annotationIds[0] ?? null; - setActiveEvidence(item.id, annId); - - const target = activeState.activeTarget; - if (target?.targetType === "form-field") { - tryLink(item.id, target.targetId); - } - }, - [activeState.activeTarget, setActiveEvidence, tryLink], - ); - - const handleUnlink = useCallback( - (link: EvidenceLink) => { - bindings.unlinkEvidence(link.id); - if ( - activeState.activeEvidenceItemId === link.evidenceItemId && - activeState.activeTarget?.targetType === link.targetType && - activeState.activeTarget?.targetId === link.targetId - ) { - clearActiveEvidence(); - } - }, - [bindings, activeState, clearActiveEvidence], - ); - - if (!document) return null; - - return ( -
-
- Show: - { - setUserFilter("all"); - setSessionFilter(null); - }} - /> - { - setUserFilter("attached"); - setSessionFilter(null); - }} - /> -
-
- {items.length === 0 && ( -

- {effectiveFilter === "attached" - ? "No evidence linked to the active field." - : "No evidence yet. Switch to Review mode to capture a passage."} -

- )} - {items.map((item) => ( - handleCardClick(item)} - onUnlink={handleUnlink} - /> - ))} -
-
- ); -} - -function FilterToggle({ - label, - active, - onClick, -}: { - label: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function EvidenceStripCard({ - item, - isActive, - links, - fieldLabels, - onClick, - onUnlink, -}: { - item: EvidenceItem; - isActive: boolean; - links: readonly EvidenceLink[]; - fieldLabels: ReadonlyMap; - onClick: () => void; - onUnlink: (link: EvidenceLink) => void; -}) { - const engine = useEngine(); - const ref = useRef(null); - useRegisterRect("evidence-card", item.id, ref); - - const firstAnn = item.annotationIds[0] - ? engine.annotations.get(item.annotationIds[0]) - : null; - const quote = firstAnn?.quote ?? "(no quote)"; - - const formLinks = links.filter((l) => l.targetType === "form-field"); - - return ( -
- {formLinks.length > 0 && ( -
- {formLinks.map((link) => { - const label = fieldLabels.get(link.targetId) ?? link.targetId; - return ( - - ); - })} -
- )} - -
- ); -} \ No newline at end of file diff --git a/src/app/forms/ScrollBridge.tsx b/src/app/forms/ScrollBridge.tsx new file mode 100644 index 0000000..153b4b5 --- /dev/null +++ b/src/app/forms/ScrollBridge.tsx @@ -0,0 +1,19 @@ +/** + * ScrollBridge — when binder active annotation changes, scroll the viewer. + */ + +import { useEffect } from "react"; + +import { useActiveState } from "@binder/index"; +import { useScrollToAnnotation } from "@work/index"; + +export function ScrollBridge() { + const { state } = useActiveState(); + const { scrollTo } = useScrollToAnnotation(); + useEffect(() => { + if (state.activeAnnotationId) { + scrollTo(state.activeAnnotationId); + } + }, [state.activeAnnotationId, scrollTo]); + return null; +} diff --git a/src/app/forms/demo-schema.ts b/src/app/forms/demo-schema.ts index 386f2f2..3e28fd1 100644 --- a/src/app/forms/demo-schema.ts +++ b/src/app/forms/demo-schema.ts @@ -1,10 +1,11 @@ /** - * Demo form schema for CE-WP-0003 (the form-binding slice). + * Demo attributes schema for Capture mode (CE-WP-0003 form-binding slice; + * CE-WP-0010 renames the user-facing concept from "form" to "attributes"). * * Deliberately minimal: text, textarea, date. JSON Schema is **not** used - * here — that's deferred to a later ADR. The MVP form's only job is to - * render a handful of fields and accept evidence links so the visual-guide - * round-trip can be exercised end-to-end. + * here — that's deferred to a later ADR. The schema's only job is to render + * a handful of key/value attributes and accept evidence links so the + * visual-guide round-trip can be exercised end-to-end. */ export type FormFieldSchema = @@ -20,7 +21,7 @@ export interface FormSchema { export const DEMO_SCHEMA: FormSchema = { id: "demo-form", - title: "Demo evidence-backed form", + title: "Attributes", fields: [ { type: "textarea", id: "summary", label: "Summary of the matter" }, { type: "date", id: "deadline", label: "Key deadline" }, diff --git a/tests/integration/capture-session-persist.dom.test.tsx b/tests/integration/capture-session-persist.dom.test.tsx index ef4c267..dfffa1c 100644 --- a/tests/integration/capture-session-persist.dom.test.tsx +++ b/tests/integration/capture-session-persist.dom.test.tsx @@ -57,9 +57,9 @@ describe("Capture session persistence", () => { const user = userEvent.setup(); const first = await loadApp(); - const summary = screen.getByLabelText("Summary of the matter"); + const summary = screen.getByRole("textbox", { name: /Summary of the matter/ }); await user.type(summary, "Persisted summary text"); - await user.click(screen.getByLabelText("Disputed amount")); + await user.click(screen.getByRole("textbox", { name: /Disputed amount/ })); await waitFor(() => { const stored = Object.values(globalThis.localStorage ?? {}).join(""); @@ -70,7 +70,7 @@ describe("Capture session persistence", () => { await loadApp(); - const restored = screen.getByLabelText("Summary of the matter") as HTMLTextAreaElement; + const restored = screen.getByRole("textbox", { name: /Summary of the matter/ }) as HTMLTextAreaElement; await waitFor(() => { expect(restored.value).toBe("Persisted summary text"); }); @@ -84,7 +84,7 @@ describe("Capture session persistence", () => { const user = userEvent.setup(); await loadApp(); - await user.type(screen.getByLabelText("Disputed amount"), "EUR 500"); + await user.type(screen.getByRole("textbox", { name: /Disputed amount/ }), "EUR 500"); await waitFor(() => { const keys = Object.keys(globalThis.localStorage ?? {}); diff --git a/tests/integration/forms-active-cycling.dom.test.tsx b/tests/integration/forms-active-cycling.dom.test.tsx index 62d0520..3f36971 100644 --- a/tests/integration/forms-active-cycling.dom.test.tsx +++ b/tests/integration/forms-active-cycling.dom.test.tsx @@ -144,7 +144,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => { }); // Focus Summary, then click strip card → link gets created. - const summaryField = screen.getByLabelText("Summary of the matter"); + const summaryField = screen.getByRole("textbox", { name: /Summary of the matter/ }); await user.click(summaryField); await user.click(stripCard); @@ -167,7 +167,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => { // // Note: clicking the same field doesn't fire onFocus if it's already // focused. Move focus elsewhere first, then back. - await user.click(screen.getByLabelText("Disputed amount")); + await user.click(screen.getByRole("textbox", { name: /Disputed amount/ })); await user.click(summaryField); // The strip card for the linked evidence has aria-current="true". diff --git a/tests/integration/forms-field-edit.dom.test.tsx b/tests/integration/forms-field-edit.dom.test.tsx index 19dc5f1..252caad 100644 --- a/tests/integration/forms-field-edit.dom.test.tsx +++ b/tests/integration/forms-field-edit.dom.test.tsx @@ -104,13 +104,22 @@ describe("Capture — field add/edit UX (CE-WP-0007-T10/T11)", () => { await user.selectOptions(screen.getByTestId("field-add-type-select"), "date"); await user.click(screen.getByTestId("field-add-save")); - const hearingInput = await screen.findByLabelText("Hearing date"); + const hearingInput = await waitFor(() => { + const inputs = Array.from( + document.querySelectorAll('input[type="date"]'), + ) as HTMLInputElement[]; + const match = inputs.find((el) => + (el.labels?.[0]?.textContent ?? "").includes("Hearing date"), + ); + if (!match) throw new Error("expected Hearing date input"); + return match; + }); expect(hearingInput.getAttribute("type")).toBe("date"); }, ); it( - "edits an existing field label and type via the pencil icon", + "edits an existing attribute key and type via the pencil icon", { timeout: 15000 }, async () => { const user = userEvent.setup(); @@ -130,8 +139,8 @@ describe("Capture — field add/edit UX (CE-WP-0007-T10/T11)", () => { ); await user.click(screen.getByTestId("field-edit-summary-save")); - await screen.findByLabelText("Matter summary"); - expect(screen.queryByLabelText("Summary of the matter")).toBeNull(); + await screen.findByRole("textbox", { name: /Matter summary/ }); + expect(screen.queryByRole("textbox", { name: /Summary of the matter/ })).toBeNull(); }, ); @@ -156,7 +165,7 @@ describe("Capture — field add/edit UX (CE-WP-0007-T10/T11)", () => { await user.type(addLabel, "Custom slot"); await user.click(screen.getByTestId("field-add-save")); - const customField = await screen.findByLabelText("Custom slot"); + const customField = await screen.findByRole("textbox", { name: /Custom slot/ }); await user.click(customField); const stripCard = screen.getByRole("button", { name: /Link to custom field/ }); diff --git a/tests/integration/forms-field-values.dom.test.tsx b/tests/integration/forms-field-values.dom.test.tsx index 1e3d026..3c617f4 100644 --- a/tests/integration/forms-field-values.dom.test.tsx +++ b/tests/integration/forms-field-values.dom.test.tsx @@ -51,9 +51,9 @@ describe("Capture — field value persistence (CE-WP-0008-T01)", () => { const user = userEvent.setup(); await loadApp(); - const summary = screen.getByLabelText("Summary of the matter"); + const summary = screen.getByRole("textbox", { name: /Summary of the matter/ }); await user.type(summary, "Tenant owes arrears"); - await user.click(screen.getByLabelText("Disputed amount")); + await user.click(screen.getByRole("textbox", { name: /Disputed amount/ })); await user.click(summary); expect((summary as HTMLTextAreaElement).value).toBe("Tenant owes arrears"); @@ -63,10 +63,10 @@ describe("Capture — field value persistence (CE-WP-0008-T01)", () => { const user = userEvent.setup(); await loadApp(); - const deadline = screen.getByLabelText("Key deadline") as HTMLInputElement; + const deadline = document.querySelector("#field-deadline") as HTMLInputElement; await user.clear(deadline); await user.type(deadline, "2026-12-15"); - await user.click(screen.getByLabelText("Disputed amount")); + await user.click(screen.getByRole("textbox", { name: /Disputed amount/ })); expect(deadline.value).toBe("2026-12-15"); }); diff --git a/tests/integration/forms-link-flow.dom.test.tsx b/tests/integration/forms-link-flow.dom.test.tsx index b209fed..0529866 100644 --- a/tests/integration/forms-link-flow.dom.test.tsx +++ b/tests/integration/forms-link-flow.dom.test.tsx @@ -109,7 +109,7 @@ describe("FormsApp — focus-gated linking (CE-WP-0007-T02)", () => { await user.click(screen.getByRole("button", { name: "Capture" })); await screen.findByRole("button", { name: /Field-first link test/ }); - await user.click(screen.getByLabelText("Disputed amount")); + await user.click(screen.getByRole("textbox", { name: /Disputed amount/ })); const stripCard = screen.getByRole("button", { name: /Field-first link test/, }); diff --git a/tests/integration/forms-overlay-e2e.dom.test.tsx b/tests/integration/forms-overlay-e2e.dom.test.tsx index 92e72d9..9aa6275 100644 --- a/tests/integration/forms-overlay-e2e.dom.test.tsx +++ b/tests/integration/forms-overlay-e2e.dom.test.tsx @@ -147,22 +147,21 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => { // CE-WP-0005: route is now session-scoped. expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/); - // Step 6: focus summary, then click the strip card to link directly. - const summaryField = screen.getByLabelText("Summary of the matter"); + // Step 6: focus summary attribute, then click the Evidence card to link. + const summaryField = screen.getByRole("textbox", { name: /Summary of the matter/ }); await user.click(summaryField); - const stripCard = await screen.findByRole("button", { + const evidenceCard = await screen.findByRole("button", { name: /Overlay E2E evidence/, }); - await user.click(stripCard); + await user.click(evidenceCard); // Move focus elsewhere and back to re-fire focus on summary so that // ActiveStateProvider triggers focus-target (the previous click that // created the link consumed the staged state). - await user.click(screen.getByLabelText("Disputed amount")); + await user.click(screen.getByRole("textbox", { name: /Disputed amount/ })); await user.click(summaryField); - // Step 8: aria-current on field row, chip, and (via the active - // state) the strip card. + // Step 8: aria-current on attribute row and active evidence card. await waitFor(() => { const fieldRow = document.querySelector( '[data-field-id="summary"][aria-current="true"]', @@ -173,7 +172,7 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => { expect(activeCard).not.toBeNull(); expect(activeCard!.textContent).toMatch(/Overlay E2E evidence/); - // Step 9: SVG overlay renders 2 paths (field→card + card→highlight). + // Step 9: SVG overlay renders 2 paths (attribute→card + card→highlight). // HighlightRectBridge registers via the mocked getHighlightClientRects. await waitFor(() => { const svg = document.querySelector('[data-testid="visual-guide-overlay"]'); diff --git a/tests/integration/forms-strip-filter.dom.test.tsx b/tests/integration/forms-strip-filter.dom.test.tsx index 0e057a9..c105faf 100644 --- a/tests/integration/forms-strip-filter.dom.test.tsx +++ b/tests/integration/forms-strip-filter.dom.test.tsx @@ -1,5 +1,6 @@ /** - * CE-WP-0006-T04 — evidence strip filter (all vs attached-to-active-field). + * CE-WP-0010-T03 — Evidence column text filter (≥3 characters). + * Replaces the CE-WP-0006 bottom-strip All/Attached toggles (strip removed). */ // @vitest-environment happy-dom @@ -70,7 +71,7 @@ async function captureAndSave(user: ReturnType, commenta await screen.findByText(new RegExp(commentary)); } -describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => { +describe("EvidenceSidebar — text filter (CE-WP-0010-T03)", () => { beforeEach(() => { viewerSnapshot.onSelectionCaptured = null; globalThis.localStorage?.clear(); @@ -78,7 +79,7 @@ describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => { history.replaceState(null, "", window.location.pathname); } seedSessionWithDoc({ - sessionName: "T04-filter", + sessionName: "T03-filter", documentTitle: FIXTURE.filename, canonicalText: SYNTHETIC_CANONICAL, }); @@ -90,7 +91,7 @@ describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => { }); it( - "narrows to attached evidence on field focus and restores on All toggle", + "filters evidence cards only when the query has 3+ characters", { timeout: 20000 }, async () => { const user = userEvent.setup(); @@ -99,26 +100,20 @@ describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => { await captureAndSave(user, "Linked to summary"); await captureAndSave(user, "Unlinked orphan"); - await user.click(screen.getByRole("button", { name: "Capture" })); - const linkedCard = await screen.findByRole("button", { - name: /Linked to summary/, - }); + expect(screen.getByRole("button", { name: /Linked to summary/ })).toBeTruthy(); expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy(); - // Link the first item to summary; field focus keeps attached filter active. - await user.click(screen.getByLabelText("Summary of the matter")); - await user.click(linkedCard); - await waitFor(() => { - expect(screen.getByTestId("field-summary-chip").textContent).toMatch( - /1 evidence/, - ); - expect(screen.queryByRole("button", { name: /Unlinked orphan/ })).toBeNull(); - }); + const filter = screen.getByTestId("evidence-filter"); + await user.type(filter, "Un"); + // Under 3 chars — both still visible + expect(screen.getByRole("button", { name: /Linked to summary/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy(); - await user.click(screen.getByRole("button", { name: "All" })); + await user.type(filter, "l"); // "Unl" await waitFor(() => { + expect(screen.queryByRole("button", { name: /Linked to summary/ })).toBeNull(); expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy(); }); }, ); -}); \ No newline at end of file +}); diff --git a/workplans/CE-WP-0010-annotate-attributes-ux.md b/workplans/CE-WP-0010-annotate-attributes-ux.md index 9d9f84d..0d3e5ea 100644 --- a/workplans/CE-WP-0010-annotate-attributes-ux.md +++ b/workplans/CE-WP-0010-annotate-attributes-ux.md @@ -7,7 +7,7 @@ repo: citation-evidence repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6 topic_slug: citation_evidence_mvp topic_id: cee7bedf-2b48-46ef-8601-006474f2ad7a -status: proposed +status: finished owner: codex created: "2026-07-30" updated: "2026-07-30" @@ -114,7 +114,7 @@ T07 (tests + copy audit) ```task id: CE-WP-0010-T01 -status: todo +status: done priority: high state_hub_task_id: "acacc281-7cc3-4833-a123-593494cafe24" ``` @@ -139,7 +139,7 @@ are updated in the same task. ```task id: CE-WP-0010-T02 -status: todo +status: done priority: high state_hub_task_id: "f8538031-3916-4f3b-a66c-2a5fd1d575f7" ``` @@ -174,7 +174,7 @@ state_hub_task_id: "f8538031-3916-4f3b-a66c-2a5fd1d575f7" ```task id: CE-WP-0010-T03 -status: todo +status: done priority: high state_hub_task_id: "5ba0e6dd-e2c9-4167-87fb-2ac7a7d81933" ``` @@ -211,7 +211,7 @@ for MVP). ```task id: CE-WP-0010-T04 -status: todo +status: done priority: high depends_on: [T02] state_hub_task_id: "3ca351b4-a039-48ad-a2a1-d95cc202b04f" @@ -234,7 +234,7 @@ Same interaction model as T03, next to the **Attributes** caption: ```task id: CE-WP-0010-T05 -status: todo +status: done priority: critical depends_on: [T02, T03] state_hub_task_id: "45e4a981-9985-49cd-bdbf-4ce8e29ae243" @@ -287,7 +287,7 @@ sidebar experience and duplicates cards under the page. ```task id: CE-WP-0010-T06 -status: todo +status: done priority: high depends_on: [T05] state_hub_task_id: "3451fe43-2909-4bf6-aa7c-72805239245b" @@ -327,7 +327,7 @@ rect-registry + Overlay stack from CE-WP-0003 where possible. ```task id: CE-WP-0010-T07 -status: todo +status: done priority: medium depends_on: [T01, T02, T03, T04, T05, T06] state_hub_task_id: "cc6802be-c910-4b5b-aa36-0ba970aff4c4"