Implement CE-WP-0010 Annotate & Attributes UX.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Rename Review→Annotate; Capture keeps Evidence and adds Attributes column
(no bottom strip); evidence/attribute filters; card→citation connectors via
shared Overlay bridges; finish workplan T01–T07.
This commit is contained in:
tegwick 2026-07-30 19:41:42 +02:00
parent dc7d8f7ce8
commit e9f7676a1a
15 changed files with 241 additions and 437 deletions

View file

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

View file

@ -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" },
],
[],

View file

@ -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 (
<div
style={{
display: "flex",
height: "100%",
fontFamily: "system-ui, sans-serif",
position: "relative",
}}
>
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
<ViewerShell />
<EvidenceSidebar />
<EvidenceSidebar onActivate={handleActivate} />
<ScrollBridge />
<HighlightRectBridge />
<EvidenceCardRectBridge />
<Overlay />
</div>
);
}

View file

@ -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-<id>"` 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;
}

View file

@ -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<Record<string, string>>(
() => ({ ...(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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<CollectionList />
<ViewerShell />
<FormPane
schema={schema}
fieldValues={fieldValues}
onFieldValueChange={handleFieldValueChange}
showAddFieldForm={showAddFieldForm}
editingFieldId={editingFieldId}
onRequestAddField={() => {
setEditingFieldId(null);
setShowAddFieldForm(true);
}}
onConfirmAddField={handleConfirmAddField}
onCancelAddField={() => setShowAddFieldForm(false)}
onBeginEditField={(fieldId) => {
setShowAddFieldForm(false);
setEditingFieldId(fieldId);
}}
onSaveFieldEdit={handleSaveFieldEdit}
onCancelFieldEdit={() => setEditingFieldId(null)}
/>
</div>
<EvidenceStrip fieldLabels={fieldLabels} />
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
<CollectionList />
<ViewerShell />
<CaptureEvidenceColumn />
<AttributesPane
schema={schema}
fieldValues={fieldValues}
onFieldValueChange={handleFieldValueChange}
showAddFieldForm={showAddFieldForm}
editingFieldId={editingFieldId}
onRequestAddField={() => {
setEditingFieldId(null);
setShowAddFieldForm(true);
}}
onConfirmAddField={handleConfirmAddField}
onCancelAddField={() => setShowAddFieldForm(false)}
onBeginEditField={(fieldId) => {
setShowAddFieldForm(false);
setEditingFieldId(fieldId);
}}
onSaveFieldEdit={handleSaveFieldEdit}
onCancelFieldEdit={() => setEditingFieldId(null)}
/>
<ScrollBridge />
<HighlightRectBridge />
<EvidenceCardRectBridge />
<Overlay />
</div>
);
}
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 <EvidenceSidebar onActivate={handleActivate} />;
}
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 (
<main
aria-label="Attributes"
style={{
flex: "0 0 320px",
minWidth: 320,
flex: "0 0 300px",
minWidth: 280,
borderLeft: "1px solid #ddd",
overflow: "auto",
display: "flex",
@ -307,304 +312,7 @@ function FormPane({
function EmptyHint() {
return (
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
Pick a document from the collection to start capturing evidence links.
Pick a document from the collection to start linking evidence to attributes.
</p>
);
}
function EvidenceStrip({
fieldLabels,
}: {
fieldLabels: ReadonlyMap<string, string>;
}) {
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<EvidenceStripFilter>("all");
const [sessionFilter, setSessionFilter] = useState<EvidenceStripFilter | null>(
null,
);
const effectiveFilter = sessionFilter ?? userFilter;
useEffect(() => {
const handler = (e: Event) => {
setSessionFilter((e as CustomEvent<EvidenceStripFilter>).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<readonly EvidenceItem[]>(() => {
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 (
<section
aria-label="Evidence list"
style={{
borderTop: "1px solid #ddd",
background: "#fafafa",
padding: 8,
display: "flex",
flexDirection: "column",
gap: 6,
flex: "0 0 auto",
minHeight: 100,
fontFamily: "system-ui, sans-serif",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
<span style={{ color: "#666" }}>Show:</span>
<FilterToggle
label="All"
active={effectiveFilter === "all"}
onClick={() => {
setUserFilter("all");
setSessionFilter(null);
}}
/>
<FilterToggle
label="Linked to field"
active={effectiveFilter === "attached"}
onClick={() => {
setUserFilter("attached");
setSessionFilter(null);
}}
/>
</div>
<div style={{ display: "flex", gap: 8, overflowX: "auto" }}>
{items.length === 0 && (
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
{effectiveFilter === "attached"
? "No evidence linked to the active field."
: "No evidence yet. Switch to Review mode to capture a passage."}
</p>
)}
{items.map((item) => (
<EvidenceStripCard
key={item.id}
item={item}
isActive={activeState.activeEvidenceItemId === item.id}
links={bindings.listTargetsForEvidence(item.id)}
fieldLabels={fieldLabels}
onClick={() => handleCardClick(item)}
onUnlink={handleUnlink}
/>
))}
</div>
</section>
);
}
function FilterToggle({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
border: active ? "1px solid #0050b3" : "1px solid #ccc",
background: active ? "#e8f0ff" : "white",
cursor: "pointer",
}}
>
{label}
</button>
);
}
function EvidenceStripCard({
item,
isActive,
links,
fieldLabels,
onClick,
onUnlink,
}: {
item: EvidenceItem;
isActive: boolean;
links: readonly EvidenceLink[];
fieldLabels: ReadonlyMap<string, string>;
onClick: () => void;
onUnlink: (link: EvidenceLink) => void;
}) {
const engine = useEngine();
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
style={{
position: "relative",
minWidth: 220,
maxWidth: 280,
flexShrink: 0,
}}
>
{formLinks.length > 0 && (
<div
style={{
position: "absolute",
top: 4,
right: 4,
display: "flex",
gap: 2,
zIndex: 1,
}}
>
{formLinks.map((link) => {
const label = fieldLabels.get(link.targetId) ?? link.targetId;
return (
<button
key={link.id}
type="button"
title={`Linked to: ${label}. Click to remove link.`}
aria-label={`Remove link to ${label}`}
onClick={(e) => {
e.stopPropagation();
onUnlink(link);
}}
style={{
fontSize: 10,
lineHeight: 1,
padding: "2px 4px",
border: "1px solid #88a",
borderRadius: 3,
background: "#eef",
cursor: "pointer",
}}
>
</button>
);
})}
</div>
)}
<button
type="button"
onClick={onClick}
aria-current={isActive ? "true" : undefined}
style={{
width: "100%",
textAlign: "left",
fontSize: 12,
padding: 8,
border: isActive ? "2px solid #0050b3" : "1px solid #ccc",
background: isActive ? "#e8f0ff" : "white",
cursor: "pointer",
}}
>
<div
style={{
fontStyle: "italic",
marginBottom: 4,
paddingRight: formLinks.length ? 24 : 0,
}}
>
&ldquo;{quote.slice(0, 100)}
{quote.length > 100 ? "…" : ""}&rdquo;
</div>
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
</button>
</div>
);
}

View file

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

View file

@ -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" },

View file

@ -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 ?? {});

View file

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

View file

@ -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/ });

View file

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

View file

@ -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/,
});

View file

@ -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"]');

View file

@ -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<typeof userEvent.setup>, 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();
});
},
);
});
});

View file

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