Unify capture/edit form, thicker active document border, layer-hide toggles

Three UX iterations rolled into one:

1. Unified evidence form
   - New EvidenceFormBody is the single source for "citation +
     commentary" editing. Both InlineCaptureForm (creating fresh
     evidence from a selection) and the EvidenceCard edit mode render
     this body with their own save/cancel labels + badge/helper text.
   - The capture form now exposes the citation as an editable
     textarea — pre-filled with the selection text — so the user can
     refine a partial capture before saving without re-selecting.
   - Old testid prefixes are unchanged for the inline-capture flow
     (`inline-capture-quote/commentary/save/cancel`); edit-mode
     testids are now `evidence-edit-<id>-{quote,commentary,save,cancel}`.

2. Active document card
   - The blue background alone was the only "this is open" cue. Added
     a 3px #0050b3 border (matching the evidence-card thick-border
     pattern, but in the documents-are-blue palette) plus a
     `data-active` attribute.

3. PDF layer-hide diagnostics
   - New debug flags `hideCanvas`, `hideTextLayer`, `hideAnnotationLayer`,
     `hideXfaLayer` — applied as `.ce-hide-<layer>` classes on the viewer
     wrapper, each `display: none`-ing the matching PDF.js layer.
   - SessionMenu groups the toggles under a "PDF diagnostics" header
     with a new shared DebugCheckbox helper. The existing "Debug text
     layer" highlight toggle now lives in the same group.
   - Lets the user isolate stacking issues by elimination — e.g.
     "hide text layer, can I now see the canvas content underneath?".

Tests
   - citation-card-export-e2e + session-export-reimport switched from
     placeholder/role-name lookups to the inline-capture testids so
     they survive form-copy changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:27:08 +02:00
parent f42b4ec87c
commit bef2725fdd
11 changed files with 309 additions and 148 deletions

View File

@@ -33,3 +33,27 @@
.ce-debug-textlayer canvas {
opacity: 0.4;
}
/*
* Layer-visibility toggles. Each `.ce-hide-<layer>` class is applied
* to the same viewer-wrapper element so a single parent can hide any
* combination of layers. Useful for diagnosing layer stacking issues
* (e.g. "is the textLayer covering the canvas?") by elimination.
*/
.ce-hide-canvas canvas {
display: none !important;
}
.ce-hide-text-layer .textLayer {
display: none !important;
}
.ce-hide-annotation-layer .annotationLayer,
.ce-hide-annotation-layer .annotationEditorLayer {
display: none !important;
}
.ce-hide-xfa-layer .xfaLayer {
display: none !important;
}

View File

@@ -199,6 +199,15 @@ export interface PdfSpikeViewerProps {
* image-only. Also logs every onSelection event to the console.
*/
readonly debugTextLayer?: boolean;
/**
* Hide specific PDF.js layers so you can see what sits underneath.
* Helps diagnose layer-stacking issues (e.g. "is the text layer
* covering the canvas content?").
*/
readonly hideCanvas?: boolean;
readonly hideTextLayer?: boolean;
readonly hideAnnotationLayer?: boolean;
readonly hideXfaLayer?: boolean;
}
export interface StoredAnnotation {
@@ -222,11 +231,24 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
activeAnnotationId,
onHighlightClicked,
debugTextLayer,
hideCanvas,
hideTextLayer,
hideAnnotationLayer,
hideXfaLayer,
} = props;
const HighlightContainer = useMemo(
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
[activeAnnotationId, onHighlightClicked],
);
const wrapperClasses = [
debugTextLayer ? "ce-debug-textlayer" : null,
hideCanvas ? "ce-hide-canvas" : null,
hideTextLayer ? "ce-hide-text-layer" : null,
hideAnnotationLayer ? "ce-hide-annotation-layer" : null,
hideXfaLayer ? "ce-hide-xfa-layer" : null,
]
.filter((c): c is string => c !== null)
.join(" ");
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
@@ -273,7 +295,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
return (
<div
className={debugTextLayer ? "ce-debug-textlayer" : undefined}
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader document={pdfUrl}>

View File

@@ -36,6 +36,10 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
const tick = useSessionListTick();
const active = useActiveSession();
const [debugTextLayer, setDebugTextLayer] = useDebugFlag("textLayer");
const [hideCanvas, setHideCanvas] = useDebugFlag("hideCanvas");
const [hideTextLayer, setHideTextLayer] = useDebugFlag("hideTextLayer");
const [hideAnnotationLayer, setHideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
const [hideXfaLayer, setHideXfaLayer] = useDebugFlag("hideXfaLayer");
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
@@ -407,25 +411,52 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
)}
<hr style={dividerStyle} />
<label
data-testid="session-menu-debug-textlayer"
<div
style={{
...menuItemStyle,
display: "flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
padding: "4px 8px",
color: "#666",
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.5,
}}
title="Paint the PDF text-layer spans in yellow so you can see what's selectable. Also logs every selection event to the browser console."
>
<input
type="checkbox"
checked={debugTextLayer}
onChange={(e) => setDebugTextLayer(e.target.checked)}
style={{ margin: 0 }}
/>
Debug text layer
</label>
PDF diagnostics
</div>
<DebugCheckbox
label="Highlight text layer"
testid="session-menu-debug-textlayer"
title="Paint the PDF text-layer spans in light grey so you can see what's selectable. Logs every selection event to the browser console."
checked={debugTextLayer}
onChange={setDebugTextLayer}
/>
<DebugCheckbox
label="Hide canvas layer"
testid="session-menu-hide-canvas"
title="Hide the rendered glyphs so only the text/annotation overlay layers remain. Use to see if the textLayer covers regions where the canvas has no content."
checked={hideCanvas}
onChange={setHideCanvas}
/>
<DebugCheckbox
label="Hide text layer"
testid="session-menu-hide-textlayer"
title="Hide the invisible selection text layer entirely. Use to see if it's covering the canvas content underneath."
checked={hideTextLayer}
onChange={setHideTextLayer}
/>
<DebugCheckbox
label="Hide annotation layer"
testid="session-menu-hide-annotation"
title="Hide PDF annotations (stamps, form widgets, links). Use to see if a stamp is obscuring content or capturing your clicks."
checked={hideAnnotationLayer}
onChange={setHideAnnotationLayer}
/>
<DebugCheckbox
label="Hide XFA layer"
testid="session-menu-hide-xfa"
title="Hide the XFA form layer (rare; only present on Adobe XFA forms)."
checked={hideXfaLayer}
onChange={setHideXfaLayer}
/>
<button
type="button"
role="menuitem"
@@ -459,6 +490,38 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
);
}
interface DebugCheckboxProps {
readonly label: string;
readonly testid: string;
readonly title: string;
readonly checked: boolean;
onChange(next: boolean): void;
}
function DebugCheckbox(p: DebugCheckboxProps) {
return (
<label
data-testid={p.testid}
title={p.title}
style={{
...menuItemStyle,
display: "flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={p.checked}
onChange={(e) => p.onChange(e.target.checked)}
style={{ margin: 0 }}
/>
{p.label}
</label>
);
}
const menuItemStyle: CSSProperties = {
display: "block",
width: "100%",

View File

@@ -106,13 +106,14 @@ export function CollectionList({ upload, title }: CollectionListProps) {
<li key={doc.id} style={{ marginBottom: 6 }}>
<div
style={{
border: "1px solid #ccc",
border: isActive ? "3px solid #0050b3" : "1px solid #ccc",
background: isActive ? "#e8f0ff" : "white",
display: "flex",
flexDirection: "column",
fontSize: 12,
}}
data-testid={`collection-item-${doc.id}`}
data-active={isActive ? "true" : "false"}
>
<button
onClick={() => setId(doc.id)}

View File

@@ -0,0 +1,104 @@
/**
* EvidenceFormBody — the shared "citation + commentary" editor.
*
* One form drives both:
* - the *capture* flow (creating evidence from a fresh selection
* inside `InlineCaptureForm`), and
* - the *edit* flow (modifying an existing evidence card inside
* `EvidenceSidebar`).
*
* Keeping a single component means changes to the field layout, hint
* placement, or save-button copy land in one file and apply to both
* surfaces. Callers control the labels and any badge/helper text.
*/
import type { CSSProperties, ReactNode } from "react";
export interface EvidenceFormBodyProps {
readonly quote: string;
readonly commentary: string;
onChangeQuote(next: string): void;
onChangeCommentary(next: string): void;
onSave(): void;
onCancel(): void;
/** Save-button label, defaults to "Save". */
readonly saveLabel?: string;
/** Cancel-button label, defaults to "Cancel". */
readonly cancelLabel?: string;
/** Short caption rendered at the top of the form, e.g. a selector
* count badge or "Editing". */
readonly badge?: ReactNode;
/** Inline note shown under the buttons (e.g. "won't move the
* marked passage" when editing an existing item). */
readonly helper?: ReactNode;
/** data-testid prefix for the input + button hooks. */
readonly testidPrefix: string;
}
export function EvidenceFormBody(p: EvidenceFormBodyProps) {
const saveLabel = p.saveLabel ?? "Save";
const cancelLabel = p.cancelLabel ?? "Cancel";
return (
<div style={{ padding: 8, fontSize: 12 }}>
{p.badge && (
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
)}
<label style={labelStyle}>Citation text</label>
<textarea
value={p.quote}
onChange={(e) => p.onChangeQuote(e.target.value)}
data-testid={`${p.testidPrefix}-quote`}
rows={3}
style={{
...textareaStyle,
fontStyle: "italic",
}}
/>
<label style={labelStyle}>Commentary</label>
<textarea
value={p.commentary}
onChange={(e) => p.onChangeCommentary(e.target.value)}
data-testid={`${p.testidPrefix}-commentary`}
rows={2}
placeholder="(optional)"
style={textareaStyle}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button
type="button"
onClick={p.onSave}
data-testid={`${p.testidPrefix}-save`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
{saveLabel}
</button>
<button
type="button"
onClick={p.onCancel}
data-testid={`${p.testidPrefix}-cancel`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
{cancelLabel}
</button>
{p.helper && (
<span style={{ fontSize: 11, color: "#888" }}>{p.helper}</span>
)}
</div>
</div>
);
}
const labelStyle: CSSProperties = {
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
};
const textareaStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
};

View File

@@ -48,6 +48,7 @@ import {
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
import { EvidenceFormBody } from "./EvidenceFormBody";
import { InlineCaptureForm } from "./InlineCaptureForm";
const TOAST_TIMEOUT_MS = 2000;
@@ -375,79 +376,18 @@ function EvidenceCard(p: EvidenceCardProps) {
}}
>
{p.isEditing ? (
<div style={{ padding: 8, fontSize: 12 }}>
<label
style={{
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
}}
>
Citation text
</label>
<textarea
value={p.editQuote}
onChange={(e) => p.onChangeQuote(e.target.value)}
data-testid={`evidence-edit-quote-${p.item.id}`}
rows={3}
style={{
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
fontStyle: "italic",
}}
/>
<label
style={{
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
}}
>
Commentary
</label>
<textarea
value={p.editCommentary}
onChange={(e) => p.onChangeCommentary(e.target.value)}
data-testid={`evidence-edit-commentary-${p.item.id}`}
rows={2}
placeholder="(empty)"
style={{
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={p.onSaveEdit}
data-testid={`evidence-edit-save-${p.item.id}`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
Save
</button>
<button
type="button"
onClick={p.onCancelEdit}
data-testid={`evidence-edit-cancel-${p.item.id}`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
Cancel
</button>
<span
style={{ fontSize: 11, color: "#888", alignSelf: "center" }}
>
The marked passage in the document stays the same.
</span>
</div>
</div>
<EvidenceFormBody
quote={p.editQuote}
commentary={p.editCommentary}
onChangeQuote={p.onChangeQuote}
onChangeCommentary={p.onChangeCommentary}
onSave={p.onSaveEdit}
onCancel={p.onCancelEdit}
saveLabel="Save"
cancelLabel="Cancel"
helper="The marked passage in the document stays the same."
testidPrefix={`evidence-edit-${p.item.id}`}
/>
) : (
<>
<button

View File

@@ -1,13 +1,17 @@
/**
* `InlineCaptureForm` — the "I just selected text, let me save it as
* evidence" form. Renders only when a pendingSelection is set; the
* EvidenceSidebar slots it into the right position in document order
* so the form appears between the cards that bracket the new
* selection.
* evidence" form. Renders only when a `pendingSelection` is set;
* `EvidenceSidebar` slots it into the right position in document
* order so the new capture appears between the cards that bracket
* it.
*
* Behaviour matches the pre-iteration AnnotationToolbar (which used to
* live above the viewer):
* Uses the shared `EvidenceFormBody` so the field layout matches the
* edit form on an existing card. The user can refine the
* auto-captured citation text before saving (handy when the
* underlying text layer captured fragments — they can paste in the
* correct quote without re-selecting).
*
* Save pipeline:
* 1. `createSelectors(capture, representation)` — anchor builds the
* maximal selector set against the active representation.
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
@@ -25,15 +29,18 @@ import {
useEngine,
usePendingSelection,
} from "./EngineContext";
import { EvidenceFormBody } from "./EvidenceFormBody";
export function InlineCaptureForm() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { pending, set } = usePendingSelection();
const [quote, setQuote] = useState("");
const [commentary, setCommentary] = useState("");
// Reset the commentary box whenever a fresh selection arrives.
// Re-seed the form whenever a fresh selection arrives.
useEffect(() => {
setQuote(pending?.capture.text ?? "");
setCommentary("");
}, [pending]);
@@ -45,7 +52,7 @@ export function InlineCaptureForm() {
documentId: document.id,
representationId: representation.id,
selectors,
quote: pending.capture.text,
quote: quote.trim().length > 0 ? quote : pending.capture.text,
});
engine.evidence.create({
annotationIds: [annotation.id],
@@ -56,8 +63,7 @@ export function InlineCaptureForm() {
const handleDiscard = () => set(null);
const quote = pending.capture.text;
const shortQuote = quote.length > 200 ? `${quote.slice(0, 200)}` : quote;
const selectorCount = pending.selectors.length;
return (
<div
@@ -65,49 +71,28 @@ export function InlineCaptureForm() {
style={{
border: "1px dashed #b78b1c",
background: "#fff8d6",
padding: 8,
marginBottom: 8,
borderRadius: 2,
fontSize: 12,
}}
>
<div style={{ marginBottom: 6, fontWeight: 600 }}>
New annotation ({pending.selectors.length} selector
{pending.selectors.length === 1 ? "" : "s"})
</div>
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
&ldquo;{shortQuote}&rdquo;
</div>
<textarea
value={commentary}
onChange={(e) => setCommentary(e.target.value)}
placeholder="Add a one-line comment (optional)…"
rows={2}
style={{
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
}}
<EvidenceFormBody
quote={quote}
commentary={commentary}
onChangeQuote={setQuote}
onChangeCommentary={setCommentary}
onSave={handleSave}
onCancel={handleDiscard}
saveLabel="Save evidence"
cancelLabel="Discard"
badge={
<>
New evidence (
{selectorCount} selector{selectorCount === 1 ? "" : "s"}
) refine the citation if needed
</>
}
testidPrefix="inline-capture"
/>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={handleSave}
data-testid="inline-capture-save"
style={{ fontSize: 12, padding: "4px 10px" }}
>
Save evidence
</button>
<button
type="button"
onClick={handleDiscard}
style={{ fontSize: 12, padding: "4px 10px" }}
>
Discard
</button>
</div>
</div>
);
}

View File

@@ -32,6 +32,10 @@ export function ViewerShell() {
const { set: setPending } = usePendingSelection();
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
const [debugTextLayer] = useDebugFlag("textLayer");
const [hideCanvas] = useDebugFlag("hideCanvas");
const [hideTextLayer] = useDebugFlag("hideTextLayer");
const [hideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
const [hideXfaLayer] = useDebugFlag("hideXfaLayer");
const activeEvidenceId = useLastActivatedEvidence();
// The viewer needs to re-fetch its highlight list whenever annotations
@@ -108,16 +112,29 @@ export function ViewerShell() {
>
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer
// Re-key on scrollVersion so clicking the same item twice still
// triggers the viewer's mount-time scroll effect. Also re-key on
// debugTextLayer so toggling it remounts the viewer.
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
// Re-key on scrollVersion + debug flags so toggling any of
// them remounts the viewer (the CSS classes are on a parent,
// but a re-render is the simplest way to make sure the layers
// get re-laid-out).
key={[
document.id,
scrollVersion,
debugTextLayer ? "d" : "n",
hideCanvas ? "hc" : "",
hideTextLayer ? "ht" : "",
hideAnnotationLayer ? "ha" : "",
hideXfaLayer ? "hx" : "",
].join("#")}
pdfUrl={fileUrl}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer}
hideCanvas={hideCanvas}
hideTextLayer={hideTextLayer}
hideAnnotationLayer={hideAnnotationLayer}
hideXfaLayer={hideXfaLayer}
onSelectionCaptured={(capture, selectors) => {
setPending({ capture, selectors });
}}

View File

@@ -15,7 +15,12 @@ import { useCallback, useEffect, useState } from "react";
const KEY_PREFIX = "citation-evidence:debug:";
const STORAGE_EVENT = "ce-debug-flag-change";
export type DebugFlag = "textLayer";
export type DebugFlag =
| "textLayer"
| "hideCanvas"
| "hideTextLayer"
| "hideAnnotationLayer"
| "hideXfaLayer";
function storageKey(flag: DebugFlag): string {
return `${KEY_PREFIX}${flag}`;

View File

@@ -138,10 +138,10 @@ describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
);
});
await user.type(
screen.getByPlaceholderText(/Add a one-line comment/),
screen.getByTestId("inline-capture-commentary"),
"Export E2E commentary",
);
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await user.click(screen.getByTestId("inline-capture-save"));
await screen.findByText(/Export E2E commentary/);
// --- Step 10: click Export → Copy as Markdown ----------------------

View File

@@ -251,10 +251,10 @@ describe("CE-WP-0005-T08 — full create → annotate → export → reimport E2
);
});
await user.type(
screen.getByPlaceholderText(/Add a one-line comment/),
screen.getByTestId("inline-capture-commentary"),
"E2E session commentary",
);
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await user.click(screen.getByTestId("inline-capture-save"));
await screen.findByText(/E2E session commentary/);
// Step 5 (sanity): export the evidence as Markdown via the sidebar.