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 |
|
||||
|----------|-------|--------|
|
||||
| `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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 │
|
||||
* ├────────────┴─────────────────┴─────────────┤
|
||||
* │ 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,
|
||||
}}
|
||||
>
|
||||
“{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
|
||||
* 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" },
|
||||
|
|
|
|||
|
|
@ -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 ?? {});
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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/ });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue