Implement CE-WP-0010 Annotate & Attributes UX.
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:
parent
dc7d8f7ce8
commit
e9f7676a1a
15 changed files with 241 additions and 437 deletions
|
|
@ -31,7 +31,7 @@ publish tasks wait on ADR-0002 resolution.
|
||||||
|
|
||||||
| Workplan | Title | Status |
|
| 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
|
User-facing polish after manual document review: rename Review→Annotate, keep
|
||||||
Evidence column in Capture with Attributes on the right, list filters, and
|
Evidence column in Capture with Attributes on the right, list filters, and
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,7 @@ function ActiveTopBar({
|
||||||
|
|
||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ id: "review" as const, label: "Review" },
|
{ id: "review" as const, label: "Annotate" },
|
||||||
{ id: "forms" as const, label: "Capture" },
|
{ id: "forms" as const, label: "Capture" },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
|
|
|
||||||
|
|
@ -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 │
|
* │ Collection │ Document Viewer │ Evidence │
|
||||||
* │ List │ │ Sidebar │
|
|
||||||
* └────────────┴──────────────────┴────────────┘
|
* └────────────┴──────────────────┴────────────┘
|
||||||
*
|
*
|
||||||
* CE-WP-0005 added an `upload` slot for the active session's upload
|
* CE-WP-0005: `upload` slot for the active session's dropzone.
|
||||||
* dropzone, threaded in by the app composition root so this component
|
* CE-WP-0010: evidence → citation connector via Overlay + card/highlight bridges.
|
||||||
* stays inside the `work` boundary (which cannot import `app`).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { useCallback, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
|
||||||
|
import { Overlay, useActiveState } from "@binder/index";
|
||||||
import {
|
import {
|
||||||
CollectionList,
|
CollectionList,
|
||||||
EvidenceSidebar,
|
EvidenceSidebar,
|
||||||
|
|
@ -20,23 +21,41 @@ import {
|
||||||
useActiveSession,
|
useActiveSession,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
|
import { EvidenceCardRectBridge } from "./forms/EvidenceCardRectBridge";
|
||||||
|
import { HighlightRectBridge } from "./forms/HighlightRectBridge";
|
||||||
|
import { ScrollBridge } from "./forms/ScrollBridge";
|
||||||
|
|
||||||
export interface ReviewLayoutProps {
|
export interface ReviewLayoutProps {
|
||||||
readonly upload?: ReactNode;
|
readonly upload?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewLayout({ upload }: ReviewLayoutProps) {
|
export function ReviewLayout({ upload }: ReviewLayoutProps) {
|
||||||
const session = useActiveSession();
|
const session = useActiveSession();
|
||||||
|
const { setActiveEvidence } = useActiveState();
|
||||||
|
|
||||||
|
const handleActivate = useCallback(
|
||||||
|
(item: EvidenceItem) => {
|
||||||
|
setActiveEvidence(item.id, item.annotationIds[0] ?? null);
|
||||||
|
},
|
||||||
|
[setActiveEvidence],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
|
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
|
||||||
<ViewerShell />
|
<ViewerShell />
|
||||||
<EvidenceSidebar />
|
<EvidenceSidebar onActivate={handleActivate} />
|
||||||
|
<ScrollBridge />
|
||||||
|
<HighlightRectBridge />
|
||||||
|
<EvidenceCardRectBridge />
|
||||||
|
<Overlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
src/app/forms/EvidenceCardRectBridge.tsx
Normal file
54
src/app/forms/EvidenceCardRectBridge.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 │
|
* │ Collection │ ViewerShell │ Evidence │ Attributes │
|
||||||
* ├────────────┴─────────────────┴─────────────┤
|
* └────────────┴─────────────────┴──────────┴────────────┘
|
||||||
* │ EvidenceStrip (bottom) │
|
|
||||||
* └────────────────────────────────────────────┘
|
|
||||||
*
|
*
|
||||||
* 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 { EvidenceItem } from "@shared/evidence";
|
||||||
import type { EvidenceLink } from "@shared/evidence-link";
|
import type { EvidenceItemId, SessionId } from "@shared/ids";
|
||||||
import type { EvidenceItemId } from "@shared/ids";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Overlay,
|
Overlay,
|
||||||
useActiveState,
|
useActiveState,
|
||||||
useBinder,
|
useBinder,
|
||||||
useRegisterRect,
|
|
||||||
} from "@binder/index";
|
} from "@binder/index";
|
||||||
import type { SessionId } from "@shared/ids";
|
|
||||||
|
|
||||||
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
||||||
import {
|
import {
|
||||||
CollectionList,
|
CollectionList,
|
||||||
|
EvidenceSidebar,
|
||||||
ViewerShell,
|
ViewerShell,
|
||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
useScrollToAnnotation,
|
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer";
|
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 { persistCapturePatch } from "./capture-persistence";
|
||||||
import { DEMO_SCHEMA } from "./demo-schema";
|
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 {
|
function quotePreview(text: string, max = 80): string {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
|
|
@ -73,11 +61,6 @@ export function FormsApp({
|
||||||
: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] },
|
: { ...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>>(
|
const [fieldValues, setFieldValues] = useState<Record<string, string>>(
|
||||||
() => ({ ...(initialFieldValues ?? {}) }),
|
() => ({ ...(initialFieldValues ?? {}) }),
|
||||||
);
|
);
|
||||||
|
|
@ -109,7 +92,7 @@ export function FormsApp({
|
||||||
const field: FormFieldSchema = {
|
const field: FormFieldSchema = {
|
||||||
id,
|
id,
|
||||||
type: patch.type,
|
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] };
|
return { ...prev, fields: [...prev.fields, field] };
|
||||||
});
|
});
|
||||||
|
|
@ -138,50 +121,77 @@ export function FormsApp({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
|
||||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
<CollectionList />
|
||||||
<CollectionList />
|
<ViewerShell />
|
||||||
<ViewerShell />
|
<CaptureEvidenceColumn />
|
||||||
<FormPane
|
<AttributesPane
|
||||||
schema={schema}
|
schema={schema}
|
||||||
fieldValues={fieldValues}
|
fieldValues={fieldValues}
|
||||||
onFieldValueChange={handleFieldValueChange}
|
onFieldValueChange={handleFieldValueChange}
|
||||||
showAddFieldForm={showAddFieldForm}
|
showAddFieldForm={showAddFieldForm}
|
||||||
editingFieldId={editingFieldId}
|
editingFieldId={editingFieldId}
|
||||||
onRequestAddField={() => {
|
onRequestAddField={() => {
|
||||||
setEditingFieldId(null);
|
setEditingFieldId(null);
|
||||||
setShowAddFieldForm(true);
|
setShowAddFieldForm(true);
|
||||||
}}
|
}}
|
||||||
onConfirmAddField={handleConfirmAddField}
|
onConfirmAddField={handleConfirmAddField}
|
||||||
onCancelAddField={() => setShowAddFieldForm(false)}
|
onCancelAddField={() => setShowAddFieldForm(false)}
|
||||||
onBeginEditField={(fieldId) => {
|
onBeginEditField={(fieldId) => {
|
||||||
setShowAddFieldForm(false);
|
setShowAddFieldForm(false);
|
||||||
setEditingFieldId(fieldId);
|
setEditingFieldId(fieldId);
|
||||||
}}
|
}}
|
||||||
onSaveFieldEdit={handleSaveFieldEdit}
|
onSaveFieldEdit={handleSaveFieldEdit}
|
||||||
onCancelFieldEdit={() => setEditingFieldId(null)}
|
onCancelFieldEdit={() => setEditingFieldId(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<EvidenceStrip fieldLabels={fieldLabels} />
|
|
||||||
<ScrollBridge />
|
<ScrollBridge />
|
||||||
<HighlightRectBridge />
|
<HighlightRectBridge />
|
||||||
|
<EvidenceCardRectBridge />
|
||||||
<Overlay />
|
<Overlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScrollBridge() {
|
/**
|
||||||
const { state } = useActiveState();
|
* Evidence sidebar wired for Capture: activate sets binder active evidence
|
||||||
const { scrollTo } = useScrollToAnnotation();
|
* and links to the focused attribute when present.
|
||||||
useEffect(() => {
|
*/
|
||||||
if (state.activeAnnotationId) {
|
function CaptureEvidenceColumn() {
|
||||||
scrollTo(state.activeAnnotationId);
|
const { state: activeState, setActiveEvidence } = useActiveState();
|
||||||
}
|
const { bindings } = useBinder();
|
||||||
}, [state.activeAnnotationId, scrollTo]);
|
|
||||||
return null;
|
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,
|
schema,
|
||||||
fieldValues,
|
fieldValues,
|
||||||
onFieldValueChange,
|
onFieldValueChange,
|
||||||
|
|
@ -213,12 +223,6 @@ function FormPane({
|
||||||
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
||||||
const { state: activeState, setActiveEvidence } = useActiveState();
|
const { state: activeState, setActiveEvidence } = useActiveState();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return engine.bus.on("FormFieldActivated", () => {
|
|
||||||
publishStripFilter("attached");
|
|
||||||
});
|
|
||||||
}, [engine]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = activeState.activeTarget;
|
const target = activeState.activeTarget;
|
||||||
if (!target || activeState.activeEvidenceItemId) return;
|
if (!target || activeState.activeEvidenceItemId) return;
|
||||||
|
|
@ -272,9 +276,10 @@ function FormPane({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
|
aria-label="Attributes"
|
||||||
style={{
|
style={{
|
||||||
flex: "0 0 320px",
|
flex: "0 0 300px",
|
||||||
minWidth: 320,
|
minWidth: 280,
|
||||||
borderLeft: "1px solid #ddd",
|
borderLeft: "1px solid #ddd",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -307,304 +312,7 @@ function FormPane({
|
||||||
function EmptyHint() {
|
function EmptyHint() {
|
||||||
return (
|
return (
|
||||||
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
|
<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>
|
</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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
“{quote.slice(0, 100)}
|
|
||||||
{quote.length > 100 ? "…" : ""}”
|
|
||||||
</div>
|
|
||||||
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
19
src/app/forms/ScrollBridge.tsx
Normal file
19
src/app/forms/ScrollBridge.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
* 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
|
* here — that's deferred to a later ADR. The schema's only job is to render
|
||||||
* render a handful of fields and accept evidence links so the visual-guide
|
* a handful of key/value attributes and accept evidence links so the
|
||||||
* round-trip can be exercised end-to-end.
|
* visual-guide round-trip can be exercised end-to-end.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type FormFieldSchema =
|
export type FormFieldSchema =
|
||||||
|
|
@ -20,7 +21,7 @@ export interface FormSchema {
|
||||||
|
|
||||||
export const DEMO_SCHEMA: FormSchema = {
|
export const DEMO_SCHEMA: FormSchema = {
|
||||||
id: "demo-form",
|
id: "demo-form",
|
||||||
title: "Demo evidence-backed form",
|
title: "Attributes",
|
||||||
fields: [
|
fields: [
|
||||||
{ type: "textarea", id: "summary", label: "Summary of the matter" },
|
{ type: "textarea", id: "summary", label: "Summary of the matter" },
|
||||||
{ type: "date", id: "deadline", label: "Key deadline" },
|
{ type: "date", id: "deadline", label: "Key deadline" },
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,9 @@ describe("Capture session persistence", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const first = await loadApp();
|
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.type(summary, "Persisted summary text");
|
||||||
await user.click(screen.getByLabelText("Disputed amount"));
|
await user.click(screen.getByRole("textbox", { name: /Disputed amount/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const stored = Object.values(globalThis.localStorage ?? {}).join("");
|
const stored = Object.values(globalThis.localStorage ?? {}).join("");
|
||||||
|
|
@ -70,7 +70,7 @@ describe("Capture session persistence", () => {
|
||||||
|
|
||||||
await loadApp();
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(restored.value).toBe("Persisted summary text");
|
expect(restored.value).toBe("Persisted summary text");
|
||||||
});
|
});
|
||||||
|
|
@ -84,7 +84,7 @@ describe("Capture session persistence", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await loadApp();
|
await loadApp();
|
||||||
|
|
||||||
await user.type(screen.getByLabelText("Disputed amount"), "EUR 500");
|
await user.type(screen.getByRole("textbox", { name: /Disputed amount/ }), "EUR 500");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const keys = Object.keys(globalThis.localStorage ?? {});
|
const keys = Object.keys(globalThis.localStorage ?? {});
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus Summary, then click strip card → link gets created.
|
// 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(summaryField);
|
||||||
await user.click(stripCard);
|
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
|
// Note: clicking the same field doesn't fire onFocus if it's already
|
||||||
// focused. Move focus elsewhere first, then back.
|
// 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);
|
await user.click(summaryField);
|
||||||
|
|
||||||
// The strip card for the linked evidence has aria-current="true".
|
// The strip card for the linked evidence has aria-current="true".
|
||||||
|
|
|
||||||
|
|
@ -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.selectOptions(screen.getByTestId("field-add-type-select"), "date");
|
||||||
await user.click(screen.getByTestId("field-add-save"));
|
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");
|
expect(hearingInput.getAttribute("type")).toBe("date");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it(
|
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 },
|
{ timeout: 15000 },
|
||||||
async () => {
|
async () => {
|
||||||
const user = userEvent.setup();
|
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 user.click(screen.getByTestId("field-edit-summary-save"));
|
||||||
|
|
||||||
await screen.findByLabelText("Matter summary");
|
await screen.findByRole("textbox", { name: /Matter summary/ });
|
||||||
expect(screen.queryByLabelText("Summary of the matter")).toBeNull();
|
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.type(addLabel, "Custom slot");
|
||||||
await user.click(screen.getByTestId("field-add-save"));
|
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);
|
await user.click(customField);
|
||||||
|
|
||||||
const stripCard = screen.getByRole("button", { name: /Link to custom field/ });
|
const stripCard = screen.getByRole("button", { name: /Link to custom field/ });
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ describe("Capture — field value persistence (CE-WP-0008-T01)", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await loadApp();
|
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.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);
|
await user.click(summary);
|
||||||
|
|
||||||
expect((summary as HTMLTextAreaElement).value).toBe("Tenant owes arrears");
|
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();
|
const user = userEvent.setup();
|
||||||
await loadApp();
|
await loadApp();
|
||||||
|
|
||||||
const deadline = screen.getByLabelText("Key deadline") as HTMLInputElement;
|
const deadline = document.querySelector("#field-deadline") as HTMLInputElement;
|
||||||
await user.clear(deadline);
|
await user.clear(deadline);
|
||||||
await user.type(deadline, "2026-12-15");
|
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");
|
expect(deadline.value).toBe("2026-12-15");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ describe("FormsApp — focus-gated linking (CE-WP-0007-T02)", () => {
|
||||||
await user.click(screen.getByRole("button", { name: "Capture" }));
|
await user.click(screen.getByRole("button", { name: "Capture" }));
|
||||||
await screen.findByRole("button", { name: /Field-first link test/ });
|
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", {
|
const stripCard = screen.getByRole("button", {
|
||||||
name: /Field-first link test/,
|
name: /Field-first link test/,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// CE-WP-0005: route is now session-scoped.
|
||||||
expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/);
|
expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/);
|
||||||
|
|
||||||
// Step 6: focus summary, then click the strip card to link directly.
|
// Step 6: focus summary attribute, then click the Evidence card to link.
|
||||||
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(summaryField);
|
||||||
const stripCard = await screen.findByRole("button", {
|
const evidenceCard = await screen.findByRole("button", {
|
||||||
name: /Overlay E2E evidence/,
|
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
|
// Move focus elsewhere and back to re-fire focus on summary so that
|
||||||
// ActiveStateProvider triggers focus-target (the previous click that
|
// ActiveStateProvider triggers focus-target (the previous click that
|
||||||
// created the link consumed the staged state).
|
// 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);
|
await user.click(summaryField);
|
||||||
|
|
||||||
// Step 8: aria-current on field row, chip, and (via the active
|
// Step 8: aria-current on attribute row and active evidence card.
|
||||||
// state) the strip card.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const fieldRow = document.querySelector(
|
const fieldRow = document.querySelector(
|
||||||
'[data-field-id="summary"][aria-current="true"]',
|
'[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).not.toBeNull();
|
||||||
expect(activeCard!.textContent).toMatch(/Overlay E2E evidence/);
|
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.
|
// HighlightRectBridge registers via the mocked getHighlightClientRects.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const svg = document.querySelector('[data-testid="visual-guide-overlay"]');
|
const svg = document.querySelector('[data-testid="visual-guide-overlay"]');
|
||||||
|
|
|
||||||
|
|
@ -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
|
// @vitest-environment happy-dom
|
||||||
|
|
@ -70,7 +71,7 @@ async function captureAndSave(user: ReturnType<typeof userEvent.setup>, commenta
|
||||||
await screen.findByText(new RegExp(commentary));
|
await screen.findByText(new RegExp(commentary));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => {
|
describe("EvidenceSidebar — text filter (CE-WP-0010-T03)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
viewerSnapshot.onSelectionCaptured = null;
|
viewerSnapshot.onSelectionCaptured = null;
|
||||||
globalThis.localStorage?.clear();
|
globalThis.localStorage?.clear();
|
||||||
|
|
@ -78,7 +79,7 @@ describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => {
|
||||||
history.replaceState(null, "", window.location.pathname);
|
history.replaceState(null, "", window.location.pathname);
|
||||||
}
|
}
|
||||||
seedSessionWithDoc({
|
seedSessionWithDoc({
|
||||||
sessionName: "T04-filter",
|
sessionName: "T03-filter",
|
||||||
documentTitle: FIXTURE.filename,
|
documentTitle: FIXTURE.filename,
|
||||||
canonicalText: SYNTHETIC_CANONICAL,
|
canonicalText: SYNTHETIC_CANONICAL,
|
||||||
});
|
});
|
||||||
|
|
@ -90,7 +91,7 @@ describe("FormsApp — evidence strip filter (CE-WP-0006-T04)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it(
|
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 },
|
{ timeout: 20000 },
|
||||||
async () => {
|
async () => {
|
||||||
const user = userEvent.setup();
|
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, "Linked to summary");
|
||||||
await captureAndSave(user, "Unlinked orphan");
|
await captureAndSave(user, "Unlinked orphan");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Capture" }));
|
expect(screen.getByRole("button", { name: /Linked to summary/ })).toBeTruthy();
|
||||||
const linkedCard = await screen.findByRole("button", {
|
|
||||||
name: /Linked to summary/,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy();
|
expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy();
|
||||||
|
|
||||||
// Link the first item to summary; field focus keeps attached filter active.
|
const filter = screen.getByTestId("evidence-filter");
|
||||||
await user.click(screen.getByLabelText("Summary of the matter"));
|
await user.type(filter, "Un");
|
||||||
await user.click(linkedCard);
|
// Under 3 chars — both still visible
|
||||||
await waitFor(() => {
|
expect(screen.getByRole("button", { name: /Linked to summary/ })).toBeTruthy();
|
||||||
expect(screen.getByTestId("field-summary-chip").textContent).toMatch(
|
expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy();
|
||||||
/1 evidence/,
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("button", { name: /Unlinked orphan/ })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "All" }));
|
await user.type(filter, "l"); // "Unl"
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", { name: /Linked to summary/ })).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy();
|
expect(screen.getByRole("button", { name: /Unlinked orphan/ })).toBeTruthy();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ repo: citation-evidence
|
||||||
repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
|
repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
|
||||||
topic_slug: citation_evidence_mvp
|
topic_slug: citation_evidence_mvp
|
||||||
topic_id: cee7bedf-2b48-46ef-8601-006474f2ad7a
|
topic_id: cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||||
status: proposed
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
created: "2026-07-30"
|
created: "2026-07-30"
|
||||||
updated: "2026-07-30"
|
updated: "2026-07-30"
|
||||||
|
|
@ -114,7 +114,7 @@ T07 (tests + copy audit)
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T01
|
id: CE-WP-0010-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "acacc281-7cc3-4833-a123-593494cafe24"
|
state_hub_task_id: "acacc281-7cc3-4833-a123-593494cafe24"
|
||||||
```
|
```
|
||||||
|
|
@ -139,7 +139,7 @@ are updated in the same task.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T02
|
id: CE-WP-0010-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f8538031-3916-4f3b-a66c-2a5fd1d575f7"
|
state_hub_task_id: "f8538031-3916-4f3b-a66c-2a5fd1d575f7"
|
||||||
```
|
```
|
||||||
|
|
@ -174,7 +174,7 @@ state_hub_task_id: "f8538031-3916-4f3b-a66c-2a5fd1d575f7"
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T03
|
id: CE-WP-0010-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "5ba0e6dd-e2c9-4167-87fb-2ac7a7d81933"
|
state_hub_task_id: "5ba0e6dd-e2c9-4167-87fb-2ac7a7d81933"
|
||||||
```
|
```
|
||||||
|
|
@ -211,7 +211,7 @@ for MVP).
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T04
|
id: CE-WP-0010-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T02]
|
depends_on: [T02]
|
||||||
state_hub_task_id: "3ca351b4-a039-48ad-a2a1-d95cc202b04f"
|
state_hub_task_id: "3ca351b4-a039-48ad-a2a1-d95cc202b04f"
|
||||||
|
|
@ -234,7 +234,7 @@ Same interaction model as T03, next to the **Attributes** caption:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T05
|
id: CE-WP-0010-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: critical
|
priority: critical
|
||||||
depends_on: [T02, T03]
|
depends_on: [T02, T03]
|
||||||
state_hub_task_id: "45e4a981-9985-49cd-bdbf-4ce8e29ae243"
|
state_hub_task_id: "45e4a981-9985-49cd-bdbf-4ce8e29ae243"
|
||||||
|
|
@ -287,7 +287,7 @@ sidebar experience and duplicates cards under the page.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T06
|
id: CE-WP-0010-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [T05]
|
depends_on: [T05]
|
||||||
state_hub_task_id: "3451fe43-2909-4bf6-aa7c-72805239245b"
|
state_hub_task_id: "3451fe43-2909-4bf6-aa7c-72805239245b"
|
||||||
|
|
@ -327,7 +327,7 @@ rect-registry + Overlay stack from CE-WP-0003 where possible.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CE-WP-0010-T07
|
id: CE-WP-0010-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [T01, T02, T03, T04, T05, T06]
|
depends_on: [T01, T02, T03, T04, T05, T06]
|
||||||
state_hub_task_id: "cc6802be-c910-4b5b-aa36-0ba970aff4c4"
|
state_hub_task_id: "cc6802be-c910-4b5b-aa36-0ba970aff4c4"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue