diff --git a/.gitignore b/.gitignore index 530579b3..06c79221 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ lib/**/* bun.lock .turbo/ +.agents/ .claude/ +skills-lock.json /scratch/ /playwright/.cache/ diff --git a/README.md b/README.md index 69c1b498..c888d2e5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Show proof for every AI citation. [![CI](https://img.shields.io/github/actions/workflow/status/DeepCitation/deepcitation/ci.yml?style=flat-square&label=CI)](https://github.com/DeepCitation/deepcitation/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-005595?style=flat-square)](https://opensource.org/licenses/MIT) [![Zero Dependencies](https://img.shields.io/badge/Zero%20Dependencies-trusted-005595?style=flat-square)](https://www.npmjs.com/package/deepcitation) -[![~17KB](https://img.shields.io/badge/gzip-~17KB-005595?style=flat-square)](https://bundlephobia.com/package/deepcitation) +[![~15KB](https://img.shields.io/badge/gzip-~15KB-005595?style=flat-square)](https://bundlephobia.com/package/deepcitation) diff --git a/docs/agents/animation-transition-rules.md b/docs/agents/animation-transition-rules.md index ab59bdaf..c57446e5 100644 --- a/docs/agents/animation-transition-rules.md +++ b/docs/agents/animation-transition-rules.md @@ -45,6 +45,20 @@ The popover height morph uses asymmetric durations separate from this scale: Collapse is always faster than expand — collapsing content should feel snappy and responsive, not linger. +### Ambient / Decorative Tier + +For CSS-driven passive animations only — carousels, idle-state logo fades, background motion: + +| Tier | Range | Canonical Value | Rules | +|------|-------|-----------------|-------| +| Ambient Decorative | 800–2000ms | 1500ms | CSS-only. Never JS-triggered. Never represents state the user must notice. | + +**Rules:** +- Must set `animation-play-state: paused` when `prefers-reduced-motion: reduce` is active. +- Never apply to elements triggered by user interaction — use the 5-tier scale for those. +- 1500ms is the canonical value, established by the `IntegrationStackSection` logo fade pattern. +- Durations above 2000ms will be perceived as "broken" by users; durations below 800ms will feel interactive rather than ambient. + --- ## Easing Curves diff --git a/docs/agents/canonical-exports.md b/docs/agents/canonical-exports.md index ef03de02..28507723 100644 --- a/docs/agents/canonical-exports.md +++ b/docs/agents/canonical-exports.md @@ -103,7 +103,14 @@ Open this file when importing symbols from deepcitation to find the correct cano | `VIEWPORT_MARGIN_PX` | `src/react/constants.ts` | Viewport edge margin for popover positioning (16px) | | `useAnimatedHeight()` | `src/react/hooks/useAnimatedHeight.ts` | Imperative height animation for viewState transitions | | `useAnimationState()` | `src/react/hooks/useAnimationState.ts` | Enter/exit animation lifecycle | +| `usePopoverViewState()` | `src/react/hooks/usePopoverViewState.ts` | Popover view-state machine (haptics, VT, scroll lock, escape) | | `useWheelZoom()` | `src/react/hooks/useWheelZoom.ts` | Wheel/trackpad zoom with gesture anchor | +| `buildSearchNarrative()` | `src/react/searchNarrative.ts` | SearchAttempt[] → display-ready narrative for VerificationLog | +| `getStatusColorScheme()` | `src/react/searchNarrative.ts` | SearchStatus → color scheme string | +| `getStatusHeaderText()` | `src/react/searchNarrative.ts` | SearchStatus → localized header text | +| `buildIntentSummary()` | `src/react/searchSummaryUtils.ts` | Intent summary from search attempts | +| `buildSearchSummary()` | `src/react/searchSummaryUtils.ts` | Full search summary with query groups | +| `deriveContextWindow()` | `src/react/searchSummaryUtils.ts` | Context window derivation from attempts | | `EXPANDED_POPOVER_MID_WIDTH` | `src/react/expandedWidthPolicy.ts` | Mid-width fallback for expanded popover states | | `getExpandedPopoverWidth()` | `src/react/expandedWidthPolicy.ts` | Computes expanded popover width from image width | | `getInteractionClasses()` | `src/react/CitationContentDisplay.utils.ts` | Hover/active interaction classes for citation triggers | diff --git a/plans/citation-list-todo.md b/plans/citation-list-todo.md new file mode 100644 index 00000000..9cd040b0 --- /dev/null +++ b/plans/citation-list-todo.md @@ -0,0 +1,169 @@ +# CitationList Component — Implementation Plan + +## Context + +The deepcitation package has two citation display extremes: +- **CitationDrawerTrigger** — one-line summary bar (icons + label). No detail without clicking. +- **CitationDrawer** — full overlay (portal, backdrop, scroll lock, drag gestures, accordion). Heavy commitment. + +**Need**: An inline, always-visible list that shows each citation's status, source, and anchor text directly in the page flow. No expand/collapse — WYSIWYG. Each row has a clickable status indicator that opens the standard citation popover for evidence details. + +## Design Decisions (from user) + +- **Independent component** — not tied to CitationDrawerTrigger +- **WYSIWYG** — all content visible, no accordion/collapse +- **Flat list** — no source grouping headers; source info per-row; sorted worst-status-first +- **Configurable maxHeight** — optional internal scrolling +- **Clickable indicator → popover** — reuses existing `CitationComponent` with `content="indicator"` for free popover integration + +## Proposed API + +```tsx +interface CitationListProps { + /** Citation groups — same data model as CitationDrawer/Trigger */ + citationGroups: SourceCitationGroup[]; + /** Optional max height; enables internal scrolling (e.g., "400px", "50vh", 300) */ + maxHeight?: string | number; + /** Additional class name */ + className?: string; + /** Status indicator style (default: "icon") */ + indicatorVariant?: IndicatorVariant; + /** Map of attachmentId/URL to friendly display label */ + sourceLabelMap?: Record; + /** Page images keyed by attachmentId (passed to popover) */ + pageImagesByAttachmentId?: Record; + /** Custom empty-state render */ + renderEmpty?: () => React.ReactNode; +} +``` + +## Component Structure + +``` +CitationList (outer div, optional maxHeight scroll) +└── CitationListRow (per citation, flat) + ├── CitationComponent content="indicator" ← clickable, opens popover + ├── FaviconImage + source name ← source identity + └── Anchor text (truncated) ← what was cited +``` + +Each row is a single horizontal line: `[✓] [favicon] SourceName anchor text preview...` + +## Key Reuse Points + +| What | From | How | +|------|------|-----| +| Clickable indicator + popover | `CitationComponent` with `content="indicator"` | Embed directly per row | +| Source favicon | `FaviconImage` from `VerificationLog.tsx` | Import and render | +| Flatten groups → sorted items | `flattenCitations` + `sortGroupsByWorstStatus` from `CitationDrawer.utils.tsx` | Call in `useMemo` | +| Resolve source labels | `resolveGroupLabels` from `CitationDrawer.utils.tsx` | Call before flattening | +| Tailwind utilities | `cn` from `utils.js` | Standard pattern | + +## Implementation Steps + +### Step 1: Create `src/react/CitationList.tsx` + +New file with: +- `CitationListProps` interface +- `CitationListRow` internal component (one row) +- `CitationList` main component +- `MemoizedCitationList` export (`React.memo` wrapper) + +**Pseudo-implementation:** + +```tsx +export function CitationList({ + citationGroups, maxHeight, className, indicatorVariant = "icon", + sourceLabelMap, pageImagesByAttachmentId, renderEmpty, +}: CitationListProps) { + const resolvedGroups = useMemo( + () => resolveGroupLabels(citationGroups, sourceLabelMap), + [citationGroups, sourceLabelMap], + ); + const flatItems = useMemo( + () => flattenCitations(sortGroupsByWorstStatus(resolvedGroups)), + [resolvedGroups], + ); + + if (flatItems.length === 0) return renderEmpty ? renderEmpty() : null; + + const scrollStyle = maxHeight ? { + maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight, + overflowY: "auto" as const, + } : undefined; + + return ( +
+ {flatItems.map(flat => ( + + ))} +
+ ); +} + +function CitationListRow({ flat, indicatorVariant, pageImagesByAttachmentId }) { + const { item, sourceName, sourceFavicon } = flat; + const anchorText = item.citation.anchorText?.toString() || item.citation.fullPhrase || ""; + + return ( +
+ {/* Clickable status indicator — opens standard popover */} + + {/* Source identity */} + + + {sourceName} + + {/* Anchor text */} + {anchorText} +
+ ); +} +``` + +### Step 2: Export from barrel (`src/react/index.ts`) + +Add near CitationDrawer exports: +```typescript +export { CitationList, MemoizedCitationList, type CitationListProps } from "./CitationList.js"; +``` + +### Step 3: Tests + +- Verify rendering with 0, 1, N citations +- Verify flat ordering (worst status first) +- Verify maxHeight creates scrollable container +- Verify CitationComponent indicator is rendered per row + +## Files to Create/Modify + +| File | Action | +|------|--------| +| `src/react/CitationList.tsx` | **Create** — new component | +| `src/react/index.ts` | **Modify** — add exports | + +## Edge Cases + +- **Empty**: Returns `renderEmpty()` or `null` +- **Single citation**: One row, no scroll +- **Many citations**: `maxHeight` enables `overflow-y: auto` +- **Pending verifications**: CitationComponent handles spinner internally +- **Mixed sources**: Each row shows its own favicon/name +- **React 19 fiber safety**: No conditional mount/unmount — every row has identical component tree + +## Verification + +```bash +bun test # existing tests pass +bun run lint # no new lint errors +bun run build # builds cleanly +``` + +Visual: component renders inline rows with clickable indicators that open popovers. diff --git a/src/__tests__/CitationDrawer.test.tsx b/src/__tests__/CitationDrawer.test.tsx index dd960d4b..8c0b0674 100644 --- a/src/__tests__/CitationDrawer.test.tsx +++ b/src/__tests__/CitationDrawer.test.tsx @@ -1324,7 +1324,7 @@ describe("CitationDrawer page badges", () => { const { container } = render( {}} citationGroups={groups} />); // Should render a clickable page pill button with aria-label (PagePill uses "Expand to full page N") - const pageButton = container.querySelector("button[aria-label='Expand to full page 3']"); + const pageButton = container.querySelector("button[aria-label*='Expand to full page 3']"); expect(pageButton).toBeInTheDocument(); expect(pageButton?.textContent).toContain("p.\u202f3"); }); @@ -1362,7 +1362,7 @@ describe("CitationDrawer page badges", () => { const { container } = render( {}} citationGroups={groups} />); // Click page 5 badge (PagePill uses "Expand to full page N") - const pageButton = container.querySelector("button[aria-label='Expand to full page 5']"); + const pageButton = container.querySelector("button[aria-label*='Expand to full page 5']"); expect(pageButton).toBeInTheDocument(); if (pageButton) fireEvent.click(pageButton); @@ -1431,7 +1431,7 @@ describe("CitationDrawer page badges", () => { />, ); - const page5Button = container.querySelector("button[aria-label='Expand to full page 5']"); + const page5Button = container.querySelector("button[aria-label*='Expand to full page 5']"); expect(page5Button).toBeInTheDocument(); if (page5Button) fireEvent.click(page5Button); @@ -1484,9 +1484,9 @@ describe("CitationDrawer page badges", () => { />, ); - const page1Button = container.querySelector("button[aria-label='Expand to full page 1']"); - const page2Button = container.querySelector("button[aria-label='Expand to full page 2']"); - const page5Button = container.querySelector("button[aria-label='Expand to full page 5']"); + const page1Button = container.querySelector("button[aria-label*='Expand to full page 1']"); + const page2Button = container.querySelector("button[aria-label*='Expand to full page 2']"); + const page5Button = container.querySelector("button[aria-label*='Expand to full page 5']"); expect(page1Button).toBeInTheDocument(); expect(page2Button).toBeInTheDocument(); expect(page5Button).toBeInTheDocument(); @@ -1527,8 +1527,8 @@ describe("CitationDrawer page badges", () => { const { container } = render( {}} citationGroups={groups} />); // Both page pills should render - const page3Button = container.querySelector("button[aria-label='Expand to full page 3']"); - const page7Button = container.querySelector("button[aria-label='Expand to full page 7']"); + const page3Button = container.querySelector("button[aria-label*='Expand to full page 3']"); + const page7Button = container.querySelector("button[aria-label*='Expand to full page 7']"); expect(page3Button).toBeInTheDocument(); expect(page7Button).toBeInTheDocument(); diff --git a/src/__tests__/EvidenceTray.test.tsx b/src/__tests__/EvidenceTray.test.tsx index aca6c22e..78b77d73 100644 --- a/src/__tests__/EvidenceTray.test.tsx +++ b/src/__tests__/EvidenceTray.test.tsx @@ -54,18 +54,23 @@ describe("EvidenceTray interaction styles", () => { } it("renders tertiary View page action with blue hover and focus ring styles", () => { - const { getByRole } = render( + // When onExpand is provided, the tray interior is aria-hidden (entire tray = one button). + // Use querySelector to find the footer CTA button in the DOM directly. + const { container } = render( {}} />, ); - const viewPageButton = getByRole("button", { name: /view page/i }); - expect(viewPageButton.className).toContain("text-dc-muted-foreground"); - expect(viewPageButton.className).toContain("hover:text-dc-foreground"); - expect(viewPageButton.className).toContain("focus-visible:ring-2"); + const viewPageButton = container.querySelector("button[aria-label*='View page']") as HTMLElement | null; + expect(viewPageButton).toBeTruthy(); + expect(viewPageButton!.className).toContain("text-dc-muted-foreground"); + expect(viewPageButton!.className).toContain("hover:text-dc-foreground"); + expect(viewPageButton!.className).toContain("focus-visible:ring-2"); }); it('uses a custom footer CTA label when provided (for example, "View image")', () => { - const { getByRole, queryByRole } = render( + // When onExpand is provided, the tray interior is aria-hidden (entire tray = one button). + // Use querySelector to find the footer CTA button in the DOM directly. + const { container } = render( { />, ); - expect(getByRole("button", { name: "View image" })).toBeInTheDocument(); - expect(queryByRole("button", { name: /view page/i })).not.toBeInTheDocument(); + expect(container.querySelector("button[aria-label='View image']")).toBeTruthy(); + expect(container.querySelector("button[aria-label*='View page']")).toBeNull(); }); it("uses Attempts wording in miss-state search toggle", () => { @@ -135,13 +140,19 @@ describe("EvidenceTray interaction styles", () => { ], }; - const { getByRole, getByText, queryByText } = render( + const { container, getByText, queryByText } = render( , ); + // When onExpand is provided the tray interior is aria-hidden — find toggle button via DOM. + const toggleButton = Array.from(container.querySelectorAll("button")).find(btn => + /1 attempt/i.test(btn.textContent ?? ""), + ); + expect(toggleButton).toBeTruthy(); + // Open search log and let React flush effects. await act(async () => { - fireEvent.click(getByRole("button", { name: /1 attempt/i })); + fireEvent.click(toggleButton!); }); const attemptRowText = getByText("alpha"); diff --git a/src/__tests__/StatusHeader.test.tsx b/src/__tests__/StatusHeader.test.tsx index a0bc83b4..b65cff91 100644 --- a/src/__tests__/StatusHeader.test.tsx +++ b/src/__tests__/StatusHeader.test.tsx @@ -134,7 +134,7 @@ describe("StatusHeader", () => { it("uses green icon color for verified status", () => { const { container } = render(); - const greenIcon = container.querySelector(".text-green-600"); + const greenIcon = container.querySelector(".text-green-700"); expect(greenIcon).toBeInTheDocument(); }); diff --git a/src/__tests__/VerificationLogComponents.test.tsx b/src/__tests__/VerificationLogComponents.test.tsx index 3a95e335..3f088378 100644 --- a/src/__tests__/VerificationLogComponents.test.tsx +++ b/src/__tests__/VerificationLogComponents.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, jest } from "@jest/globals"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { buildSearchNarrative } from "../react/searchNarrative"; import { type AmbiguityInfo, AmbiguityWarning, @@ -294,14 +295,9 @@ describe("VerificationLogTimeline attempts table", () => { }, ]; + const narrative = buildSearchNarrative(searchAttempts, "found_on_other_page", 5, 12); const { getByText } = render( - , + , ); expect(getByText("Revenue increased by 15% in Q4 2024.")).toBeInTheDocument(); @@ -324,14 +320,9 @@ describe("VerificationLogTimeline attempts table", () => { }, ]; + const narrative = buildSearchNarrative(searchAttempts, "found_on_other_line", 5, 12); const { getByText } = render( - , + , ); const unexpectedLocation = getByText(/^p[.\s\u202f]+7\s*\u00b7\s*l[.\s\u202f]+22$/); diff --git a/src/__tests__/caretIndicator.test.tsx b/src/__tests__/caretIndicator.test.tsx index 2ae10ea6..fdd44a98 100644 --- a/src/__tests__/caretIndicator.test.tsx +++ b/src/__tests__/caretIndicator.test.tsx @@ -94,10 +94,10 @@ describe("Caret Indicator Variant", () => { expect(pill.classList.contains("text-white")).toBe(true); }); - it("uses lighter gray (text-slate-400) when closed", () => { + it("uses lighter gray (text-slate-500) when closed", () => { const { container } = render(); const pill = container.querySelector("[data-dc-indicator='caret']") as HTMLElement; - expect(pill.classList.contains("text-slate-400")).toBe(true); + expect(pill.classList.contains("text-slate-500")).toBe(true); }); // ========================================================================== @@ -122,7 +122,7 @@ describe("Caret Indicator Variant", () => { const { container } = render(); const pill = container.querySelector("[data-dc-indicator='caret']") as HTMLElement; expect(pill).toBeInTheDocument(); - expect(pill.classList.contains("text-slate-400")).toBe(true); + expect(pill.classList.contains("text-slate-500")).toBe(true); }); it("renders gray caret for partial status", () => { @@ -131,7 +131,7 @@ describe("Caret Indicator Variant", () => { ); const pill = container.querySelector("[data-dc-indicator='caret']") as HTMLElement; expect(pill).toBeInTheDocument(); - expect(pill.classList.contains("text-slate-400")).toBe(true); + expect(pill.classList.contains("text-slate-500")).toBe(true); }); // ========================================================================== diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index eabfbe54..118e7c20 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -44,6 +44,55 @@ describe("DeepCitation Client", () => { }); }); + describe("requestSource", () => { + it("includes X-Request-Source header when configured", async () => { + const client = new DeepCitation({ apiKey: "sk-dc-123", requestSource: "my-app" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + attachmentId: "file_abc", + deepTextPromptPortion: "", + metadata: { filename: "t.pdf", mimeType: "application/pdf", pageCount: 1, textByteSize: 0 }, + status: "ready", + }), + } as Response); + + const blob = new Blob(["x"], { type: "application/pdf" }); + await client.uploadFile(blob, { filename: "t.pdf" }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record)["X-Request-Source"]).toBe("my-app"); + }); + + it("omits X-Request-Source header when not configured", async () => { + const client = new DeepCitation({ apiKey: "sk-dc-123" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + attachmentId: "file_abc", + deepTextPromptPortion: "", + metadata: { filename: "t.pdf", mimeType: "application/pdf", pageCount: 1, textByteSize: 0 }, + status: "ready", + }), + } as Response); + + const blob = new Blob(["x"], { type: "application/pdf" }); + await client.uploadFile(blob, { filename: "t.pdf" }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record)["X-Request-Source"]).toBeUndefined(); + }); + + it("rejects requestSource containing newline characters", () => { + expect(() => new DeepCitation({ apiKey: "sk-dc-123", requestSource: "bad\r\nvalue" })).toThrow( + "requestSource must not contain newline characters", + ); + expect(() => new DeepCitation({ apiKey: "sk-dc-123", requestSource: "bad\nvalue" })).toThrow( + "requestSource must not contain newline characters", + ); + }); + }); + describe("uploadFile", () => { it("uploads a file and returns response", async () => { const client = new DeepCitation({ apiKey: "sk-dc-123" }); diff --git a/src/__tests__/searchNarrative.test.ts b/src/__tests__/searchNarrative.test.ts new file mode 100644 index 00000000..92d0f8d3 --- /dev/null +++ b/src/__tests__/searchNarrative.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "bun:test"; +import { buildSearchNarrative } from "../react/searchNarrative"; +import type { SearchAttempt } from "../types/search"; + +describe("buildSearchNarrative", () => { + describe("outcome derivation", () => { + it("returns exact_match for 'found' status", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: true, searchPhrase: "hello world", pageSearched: 1 }, + ]; + const narrative = buildSearchNarrative(attempts, "found"); + expect(narrative.outcome).toBe("exact_match"); + expect(narrative.colorScheme).toBe("green"); + }); + + it("returns partial_match for 'found_on_other_page'", () => { + const attempts: SearchAttempt[] = [ + { method: "adjacent_pages", success: true, searchPhrase: "hello", pageSearched: 3 }, + ]; + const narrative = buildSearchNarrative(attempts, "found_on_other_page"); + expect(narrative.outcome).toBe("partial_match"); + expect(narrative.colorScheme).toBe("amber"); + }); + + it("returns not_found for 'not_found' status", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: false, searchPhrase: "missing text", pageSearched: 1 }, + ]; + const narrative = buildSearchNarrative(attempts, "not_found"); + expect(narrative.outcome).toBe("not_found"); + expect(narrative.colorScheme).toBe("red"); + }); + + it("returns pending for null status", () => { + const narrative = buildSearchNarrative([], null); + expect(narrative.outcome).toBe("pending"); + expect(narrative.colorScheme).toBe("gray"); + }); + + it("returns pending for 'loading' status", () => { + const narrative = buildSearchNarrative([], "loading"); + expect(narrative.outcome).toBe("pending"); + expect(narrative.colorScheme).toBe("gray"); + }); + }); + + describe("showAllRows", () => { + it("is false for 'found' status (show only hit)", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: true, searchPhrase: "hello", pageSearched: 1 }, + ]; + const narrative = buildSearchNarrative(attempts, "found"); + expect(narrative.showAllRows).toBe(false); + expect(narrative.rows.length).toBe(1); + expect(narrative.rows[0].kind).toBe("success"); + }); + + it("is true for 'not_found' status", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: false, searchPhrase: "missing", pageSearched: 1 }, + { method: "current_page", success: false, searchPhrase: "missing", pageSearched: 1 }, + ]; + const narrative = buildSearchNarrative(attempts, "not_found"); + expect(narrative.showAllRows).toBe(true); + }); + + it("is true for null status", () => { + const narrative = buildSearchNarrative([], null); + expect(narrative.showAllRows).toBe(true); + }); + }); + + describe("row construction", () => { + it("orders failures before successes in show-all mode", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: true, searchPhrase: "hit", pageSearched: 1 }, + { method: "current_page", success: false, searchPhrase: "miss", pageSearched: 2 }, + ]; + const narrative = buildSearchNarrative(attempts, "found_on_other_page"); + const kinds = narrative.rows.map(r => r.kind); + // Failures come first + expect(kinds.indexOf("failure")).toBeLessThan(kinds.indexOf("success")); + }); + + it("marks unexpected hit when found on different page", () => { + const attempts: SearchAttempt[] = [ + { + method: "adjacent_pages", + success: true, + searchPhrase: "text", + foundLocation: { page: 7 }, + pageSearched: 5, + }, + ]; + const narrative = buildSearchNarrative(attempts, "found_on_other_page", 5); + const successRow = narrative.rows.find(r => r.kind === "success"); + expect(successRow).toBeDefined(); + if (successRow?.kind === "success") { + expect(successRow.isUnexpectedHit).toBe(true); + } + }); + + it("builds collapsed_failure rows for not_found with page ranges", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: false, searchPhrase: "text", pageSearched: 1 }, + { method: "exact_line_match", success: false, searchPhrase: "text", pageSearched: 2 }, + { method: "exact_line_match", success: false, searchPhrase: "text", pageSearched: 3 }, + ]; + const narrative = buildSearchNarrative(attempts, "not_found"); + // Not-found grouping collapses by method category + phrase, so these should be one group + expect(narrative.rows.length).toBe(1); + expect(narrative.rows[0].kind).toBe("collapsed_failure"); + }); + }); + + describe("outcomeSummary", () => { + it("returns 'Exact match' for found with exact_full_phrase variation", () => { + const attempts: SearchAttempt[] = [ + { + method: "exact_line_match", + success: true, + searchPhrase: "hello", + matchedVariation: "exact_full_phrase", + pageSearched: 1, + }, + ]; + const narrative = buildSearchNarrative(attempts, "found"); + expect(narrative.outcomeSummary).toBe("Exact match"); + }); + + it("returns attempt count for not_found", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: false, searchPhrase: "missing", pageSearched: 1 }, + ]; + const narrative = buildSearchNarrative(attempts, "not_found"); + expect(narrative.outcomeSummary).toContain("1"); + }); + }); + + describe("totalAttempts", () => { + it("reflects the actual search attempt count", () => { + const attempts: SearchAttempt[] = [ + { method: "exact_line_match", success: false, searchPhrase: "a", pageSearched: 1 }, + { method: "current_page", success: false, searchPhrase: "a", pageSearched: 1 }, + { method: "adjacent_pages", success: false, searchPhrase: "a", pageSearched: 2 }, + ]; + const narrative = buildSearchNarrative(attempts, "not_found"); + expect(narrative.totalAttempts).toBe(3); + }); + }); +}); diff --git a/src/__tests__/usePopoverViewState.test.ts b/src/__tests__/usePopoverViewState.test.ts new file mode 100644 index 00000000..6b5cd918 --- /dev/null +++ b/src/__tests__/usePopoverViewState.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { type UsePopoverViewStateConfig, usePopoverViewState } from "../react/hooks/usePopoverViewState"; + +// Mock external dependencies that touch the DOM +const mockAcquireScrollLock = mock(() => {}); +const mockReleaseScrollLock = mock(() => {}); +const mockStartEvidenceViewTransition = mock((cb: () => void) => cb()); +const mockStartEvidencePageExpandTransition = mock((cb: () => void) => cb()); +const mockTriggerHaptic = mock(() => {}); + +mock.module("../react/scrollLock", () => ({ + acquireScrollLock: mockAcquireScrollLock, + releaseScrollLock: mockReleaseScrollLock, +})); + +mock.module("../react/viewTransition", () => ({ + startEvidenceViewTransition: mockStartEvidenceViewTransition, + startEvidencePageExpandTransition: mockStartEvidencePageExpandTransition, +})); + +mock.module("../react/haptics", () => ({ + triggerHaptic: mockTriggerHaptic, +})); + +function createConfig(overrides: Partial = {}): UsePopoverViewStateConfig { + return { + isOpen: true, + popoverContentRef: { current: null }, + ...overrides, + }; +} + +beforeEach(() => { + mockAcquireScrollLock.mockClear(); + mockReleaseScrollLock.mockClear(); + mockStartEvidenceViewTransition.mockClear(); + mockStartEvidencePageExpandTransition.mockClear(); + mockTriggerHaptic.mockClear(); + cleanup(); +}); + +describe("usePopoverViewState", () => { + it("starts in summary state", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + expect(result.current.current).toBe("summary"); + expect(result.current.expandedNaturalWidth).toBeNull(); + expect(result.current.expandedWidthSource).toBeNull(); + }); + + describe("transition", () => { + it("transitions from summary to expanded-keyhole", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + expect(result.current.current).toBe("expanded-keyhole"); + }); + + it("transitions from summary to expanded-page via page expand VT", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-page")); + expect(result.current.current).toBe("expanded-page"); + expect(mockStartEvidencePageExpandTransition).toHaveBeenCalledTimes(1); + }); + + it("uses collapse VT when going from expanded-keyhole to summary", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.transition("summary")); + expect(result.current.current).toBe("summary"); + // The second transition is a collapse — uses startEvidenceViewTransition with isCollapse + expect(mockStartEvidenceViewTransition).toHaveBeenCalled(); + }); + + it("calls onCollapseToSummary when transitioning to summary", () => { + const onCollapse = mock(() => {}); + const { result } = renderHook(() => usePopoverViewState(createConfig({ onCollapseToSummary: onCollapse }))); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.transition("summary")); + expect(onCollapse).toHaveBeenCalledTimes(1); + }); + + it("clears expanded width state when transitioning to summary", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.onExpandedWidthChange(400)); + expect(result.current.expandedNaturalWidth).toBe(400); + act(() => result.current.transition("summary")); + expect(result.current.expandedNaturalWidth).toBeNull(); + expect(result.current.expandedWidthSource).toBeNull(); + }); + }); + + describe("escape key handling", () => { + it("calls onDismiss from summary state", () => { + const onDismiss = mock(() => {}); + const { result } = renderHook(() => usePopoverViewState(createConfig({ onDismiss }))); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + act(() => result.current.onEscapeKeyDown(event)); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("navigates back from expanded-page to prevBeforeExpandedPage", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + // Go to expanded-keyhole first, then expanded-page + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.transition("expanded-page")); + expect(result.current.current).toBe("expanded-page"); + // Escape should go back to expanded-keyhole (the state before expanded-page) + const event = new KeyboardEvent("keydown", { key: "Escape" }); + act(() => result.current.onEscapeKeyDown(event)); + expect(result.current.current).toBe("expanded-keyhole"); + }); + + it("navigates from expanded-keyhole to summary on escape", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + act(() => result.current.onEscapeKeyDown(event)); + expect(result.current.current).toBe("summary"); + }); + + it("delegates to escapeInterceptRef when set", () => { + const interceptor = mock(() => {}); + const onDismiss = mock(() => {}); + const { result } = renderHook(() => usePopoverViewState(createConfig({ onDismiss }))); + result.current.escapeInterceptRef.current = interceptor; + const event = new KeyboardEvent("keydown", { key: "Escape" }); + act(() => result.current.onEscapeKeyDown(event)); + expect(interceptor).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + }); + }); + + describe("scroll lock", () => { + it("acquires scroll lock when open and in expanded-page", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig({ isOpen: true }))); + act(() => result.current.transition("expanded-page")); + expect(mockAcquireScrollLock).toHaveBeenCalledTimes(1); + }); + + it("does not acquire scroll lock in summary state", () => { + renderHook(() => usePopoverViewState(createConfig({ isOpen: true }))); + expect(mockAcquireScrollLock).not.toHaveBeenCalled(); + }); + + it("does not acquire scroll lock when not open", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig({ isOpen: false }))); + act(() => result.current.transition("expanded-page")); + expect(mockAcquireScrollLock).not.toHaveBeenCalled(); + }); + }); + + describe("resetToSummary", () => { + it("resets view state and clears width state", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.onExpandedWidthChange(500)); + act(() => result.current.resetToSummary()); + expect(result.current.current).toBe("summary"); + expect(result.current.expandedNaturalWidth).toBeNull(); + expect(result.current.expandedWidthSource).toBeNull(); + }); + + it("does not fire onCollapseToSummary", () => { + const onCollapse = mock(() => {}); + const { result } = renderHook(() => usePopoverViewState(createConfig({ onCollapseToSummary: onCollapse }))); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.resetToSummary()); + expect(onCollapse).not.toHaveBeenCalled(); + }); + + it("resets prevBeforeExpandedPageRef so next session starts clean", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + // Session 1: drill into expanded-page via expanded-keyhole + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.transition("expanded-page")); + expect(result.current.prevBeforeExpandedPageRef.current).toBe("expanded-keyhole"); + // Simulate popover close + reopen + act(() => result.current.resetToSummary()); + expect(result.current.prevBeforeExpandedPageRef.current).toBe("summary"); + // Session 2: go directly to expanded-page, escape should land on summary + act(() => result.current.transition("expanded-page")); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + act(() => result.current.onEscapeKeyDown(event)); + expect(result.current.current).toBe("summary"); + }); + }); + + describe("onExpandedWidthChange", () => { + it("stores width and source when in expanded state", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.onExpandedWidthChange(350)); + expect(result.current.expandedNaturalWidth).toBe(350); + expect(result.current.expandedWidthSource).toBe("expanded-keyhole"); + }); + + it("clears width when source override is not an expanded state", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.onExpandedWidthChange(350)); + act(() => result.current.onExpandedWidthChange(null, "summary" as any)); + expect(result.current.expandedNaturalWidth).toBeNull(); + }); + + it("respects source override parameter", () => { + const { result } = renderHook(() => usePopoverViewState(createConfig())); + act(() => result.current.transition("expanded-keyhole")); + act(() => result.current.onExpandedWidthChange(400, "expanded-page")); + expect(result.current.expandedWidthSource).toBe("expanded-page"); + }); + }); + + describe("haptics", () => { + it("fires expand haptic when going from summary to expanded-page", () => { + const { result } = renderHook(() => + usePopoverViewState(createConfig({ experimentalHaptics: true, isMobile: true })), + ); + act(() => result.current.transition("expanded-page")); + expect(mockTriggerHaptic).toHaveBeenCalledWith("expand"); + }); + + it("fires collapse haptic when going from expanded-keyhole to summary", () => { + const { result } = renderHook(() => + usePopoverViewState(createConfig({ experimentalHaptics: true, isMobile: true })), + ); + act(() => result.current.transition("expanded-keyhole")); + mockTriggerHaptic.mockClear(); + act(() => result.current.transition("summary")); + expect(mockTriggerHaptic).toHaveBeenCalledWith("collapse"); + }); + + it("does not fire haptics when experimentalHaptics is false", () => { + const { result } = renderHook(() => + usePopoverViewState(createConfig({ experimentalHaptics: false, isMobile: true })), + ); + act(() => result.current.transition("expanded-page")); + expect(mockTriggerHaptic).not.toHaveBeenCalled(); + }); + + it("does not fire haptics for intermediate transitions", () => { + const { result } = renderHook(() => + usePopoverViewState(createConfig({ experimentalHaptics: true, isMobile: true })), + ); + act(() => result.current.transition("expanded-keyhole")); + mockTriggerHaptic.mockClear(); + // expanded-keyhole → expanded-page is intermediate, no haptic + act(() => result.current.transition("expanded-page")); + expect(mockTriggerHaptic).not.toHaveBeenCalled(); + }); + }); + + describe("handle stability", () => { + it("returns a stable handle object when state has not changed", () => { + // Use a stable config object so the ref identity doesn't change across rerenders + const stableConfig = createConfig(); + const { result, rerender } = renderHook(() => usePopoverViewState(stableConfig)); + const handle1 = result.current; + rerender(); + const handle2 = result.current; + expect(handle1).toBe(handle2); + }); + + it("returns stable function references across rerenders", () => { + const stableConfig = createConfig(); + const { result, rerender } = renderHook(() => usePopoverViewState(stableConfig)); + const transition1 = result.current.transition; + const resetToSummary1 = result.current.resetToSummary; + const onEscapeKeyDown1 = result.current.onEscapeKeyDown; + rerender(); + expect(result.current.transition).toBe(transition1); + expect(result.current.resetToSummary).toBe(resetToSummary1); + expect(result.current.onEscapeKeyDown).toBe(onEscapeKeyDown1); + }); + }); +}); diff --git a/src/client/DeepCitation.ts b/src/client/DeepCitation.ts index 10a8c7c0..3de56a97 100644 --- a/src/client/DeepCitation.ts +++ b/src/client/DeepCitation.ts @@ -160,6 +160,7 @@ export class DeepCitation { private readonly endFileId?: string; private readonly convertedPdfDownloadPolicy: ConvertedPdfDownloadPolicy; private readonly onLatestVersion?: (latestVersion: string) => void; + private readonly requestSource?: string; /** * Request deduplication cache for verify calls. @@ -212,6 +213,10 @@ export class DeepCitation { this.endFileId = config.endFileId; this.convertedPdfDownloadPolicy = config.convertedPdfDownloadPolicy ?? "url_only"; this.onLatestVersion = config.onLatestVersion; + if (config.requestSource && /[\r\n]/.test(config.requestSource)) { + throw new Error("requestSource must not contain newline characters"); + } + this.requestSource = config.requestSource; } /** Resolve endUserId: per-request override wins over instance default. */ @@ -231,10 +236,14 @@ export class DeepCitation { /** Common headers included in every API request. */ private baseHeaders(): Record { - return { + const headers: Record = { Authorization: `Bearer ${this.apiKey}`, "X-SDK-Version": SDK_VERSION, }; + if (this.requestSource) { + headers["X-Request-Source"] = this.requestSource; + } + return headers; } /** If the response contains a latest SDK version header, notify the callback. */ diff --git a/src/client/types.ts b/src/client/types.ts index 0ea9c2c9..0eff7bfd 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -102,6 +102,8 @@ export interface DeepCitationConfig { * Useful for detecting when a newer SDK version is available. */ onLatestVersion?: (latestVersion: string) => void; + /** Tag identifying request origin (e.g. "playground"). Sent as X-Request-Source header. */ + requestSource?: string; } // ========================================================================== diff --git a/src/react/Citation.tsx b/src/react/Citation.tsx index 44476a2a..2f5bb124 100644 --- a/src/react/Citation.tsx +++ b/src/react/Citation.tsx @@ -29,11 +29,11 @@ import { import { DefaultPopoverContent, type PopoverViewState } from "./DefaultPopoverContent.js"; import { resolveEvidenceSrc, resolveExpandedImage } from "./EvidenceTray.js"; import { getExpandedPopoverWidthPx, getSummaryPopoverWidthPx } from "./expandedWidthPolicy.js"; -import { triggerHaptic } from "./haptics.js"; import { useExpandedPageSideOffset } from "./hooks/useExpandedPageSideOffset.js"; import { useIsTouchDevice } from "./hooks/useIsTouchDevice.js"; import { useLockedPopoverSide } from "./hooks/useLockedPopoverSide.js"; import { usePopoverAlignOffset } from "./hooks/usePopoverAlignOffset.js"; +import { usePopoverViewState } from "./hooks/usePopoverViewState.js"; import { usePrefersReducedMotion } from "./hooks/usePrefersReducedMotion.js"; import { useViewportBoundaryGuard } from "./hooks/useViewportBoundaryGuard.js"; import { type MessageKey, type TranslateFunction, useTranslation } from "./i18n.js"; @@ -41,7 +41,6 @@ import { CheckIcon, ExternalLinkIcon, LockIcon, XCircleIcon } from "./icons.js"; import { handleImageError } from "./imageUtils.js"; import { PopoverContent } from "./Popover.js"; import { Popover, PopoverTrigger } from "./PopoverPrimitives.js"; -import { acquireScrollLock, releaseScrollLock } from "./scrollLock.js"; import { REVIEW_DWELL_THRESHOLD_MS, useCitationTiming } from "./timingUtils.js"; import type { BaseCitationProps, @@ -59,11 +58,7 @@ import type { import { isBlockedStatus, isErrorStatus } from "./urlStatus.js"; import { getUrlPath, safeWindowOpen, truncateString } from "./urlUtils.js"; import { cn, generateCitationInstanceId } from "./utils.js"; -import { - isViewTransitioning, - startEvidencePageExpandTransition, - startEvidenceViewTransition, -} from "./viewTransition.js"; +import { isViewTransitioning } from "./viewTransition.js"; // Re-export types for convenience export type { @@ -540,109 +535,9 @@ export const CitationComponent = forwardRef("summary"); - const [expandedNaturalWidthForPosition, setExpandedNaturalWidthForPosition] = useState(null); - const [expandedWidthSourceForPosition, setExpandedWidthSourceForPosition] = useState< - "expanded-keyhole" | "expanded-page" | null - >(null); // Custom image src from behaviorConfig.onClick returning setImageExpanded: "" const [customExpandedSrc, setCustomExpandedSrc] = useState(null); - // Tracks which state preceded expanded-page so Escape can navigate back correctly. - // Lifted here (from DefaultPopoverContent) so onEscapeKeyDown on can read it. - const prevBeforeExpandedPageRef = useRef<"summary" | "expanded-keyhole">("summary"); - - // Set by sub-components (e.g. EvidenceTray search log) when they have an expanded - // section that should consume Escape before the popover closes. - const escapeInterceptRef = useRef<(() => void) | null>(null); - - // Ref kept in sync with popoverViewState so setViewStateWithHaptics can read - // the current value inside callbacks without stale closure issues. - // useLayoutEffect (not useEffect) ensures the ref is updated before any - // synchronous reads in the same tick — React 18 automatic batching can call - // setViewStateWithHaptics twice in one handler, and useEffect would leave - // the ref stale until after paint. - const popoverViewStateRef = useRef("summary"); - useLayoutEffect(() => { - popoverViewStateRef.current = popoverViewState; - }, [popoverViewState]); - const handleExpandedWidthChange = useCallback( - (width: number | null, sourceOverride?: "expanded-keyhole" | "expanded-page" | null) => { - const source = sourceOverride ?? popoverViewStateRef.current; - if (source !== "expanded-keyhole" && source !== "expanded-page") { - setExpandedNaturalWidthForPosition(null); - setExpandedWidthSourceForPosition(null); - return; - } - setExpandedNaturalWidthForPosition(width); - setExpandedWidthSourceForPosition(source); - }, - [], - ); - - // View-state setter that fires haptic feedback on mobile for expand/collapse - // transitions. Replaces direct setPopoverViewState calls in user-event handlers. - // closePopover still calls setPopoverViewState directly — a full dismiss is not - // a collapse in the haptic sense (it's a close, not a step-back navigation). - // - // Haptics are gated behind experimentalHaptics prop (off by default). - const setViewStateWithHaptics = useCallback( - (newState: PopoverViewState) => { - const prev = popoverViewStateRef.current; - if (experimentalHaptics && isMobile) { - // Haptic fires only on the initial expand from summary and the final - // collapse back to summary. Intermediate transitions (expanded-keyhole ↔ - // expanded-page) are silent to avoid double-pulse when the user drills - // deeper within an already-expanded state. - const isExpanding = (newState === "expanded-page" || newState === "expanded-keyhole") && prev === "summary"; - const isCollapsing = newState === "summary" && (prev === "expanded-page" || prev === "expanded-keyhole"); - if (isExpanding) triggerHaptic("expand"); - else if (isCollapsing) triggerHaptic("collapse"); - } - // Track which state we entered expanded-page from, so Escape can navigate back. - // Lifted from DefaultPopoverContent's handleExpand to eliminate a ref mutation - // that caused a React Compiler bailout in that file. - if (newState === "expanded-page" && prev !== "expanded-page") { - prevBeforeExpandedPageRef.current = prev === "expanded-keyhole" ? "expanded-keyhole" : "summary"; - } - // Determine collapse direction for View Transition timing. - // Full-page transitions are handled by an annotation-anchored VT marker - // in InlineExpandedImage — the marker is positioned at the annotation rect - // so the geometry morph tracks the annotation region, not the whole page. - // When no annotation data exists, InlineExpandedImage falls back to the - // container-level VT name (animatedShellRef), which produces the same - // crossfade behavior as before. - const ORDER: Record = { summary: 0, "expanded-keyhole": 1, "expanded-page": 2 }; - const isCollapse = ORDER[newState] < ORDER[prev]; - const commitViewState = () => { - if (newState === "summary") { - setExpandedNaturalWidthForPosition(null); - setExpandedWidthSourceForPosition(null); - } - setPopoverViewState(newState); - }; - const isPageExpand = !isCollapse && newState === "expanded-page"; - if (isPageExpand) { - startEvidencePageExpandTransition(commitViewState, { - root: popoverContentRef.current, - skipAnimation: prefersReducedMotion, - }); - return; - } - startEvidenceViewTransition(commitViewState, { isCollapse, skipAnimation: prefersReducedMotion }); - }, - [experimentalHaptics, isMobile, prefersReducedMotion], - ); - - // Lock body scroll only for expanded-page (full-viewport). Summary and - // expanded-keyhole are small overlays where scroll should pass through to - // the page behind — locking there "eats" scroll when the popover content - // isn't scrollable, trapping users. See acquireScrollLock(). - useEffect(() => { - if (!isHovering) return; - if (popoverViewState !== "expanded-page") return; - acquireScrollLock(); - return () => releaseScrollLock(); - }, [isHovering, popoverViewState]); + const clearCustomExpandedSrc = useCallback(() => setCustomExpandedSrc(null), []); // Dismiss the popover. // Keep view/layout state intact during the exit animation; resetting to @@ -680,6 +575,16 @@ export const CitationComponent = forwardRef(null); + const viewState = usePopoverViewState({ + isOpen: isHovering, + popoverContentRef, + experimentalHaptics, + isMobile, + prefersReducedMotion, + onDismiss: closePopover, + onCollapseToSummary: clearCustomExpandedSrc, + }); + // A.5.1 + A.5.2: Keyboard-open tracking, focus trap, and conditional focus return. // Isolated into a custom hook because the React Compiler can't handle a ref that's // both read in an effect (focus trap) and mutated in callbacks (click/keydown handlers). @@ -711,7 +616,7 @@ export const CitationComponent = forwardRef { const dims = verification?.evidence?.dimensions; if (!dims) return null; @@ -723,32 +628,31 @@ export const CitationComponent = forwardRef { if (!isHovering || typeof document === "undefined") return null; const viewportWidth = document.documentElement.clientWidth; - if (popoverViewState === "summary") { + if (viewState.current === "summary") { return getSummaryPopoverWidthPx(projectedSummaryKeyholeWidth, viewportWidth); } - if (expandedNaturalWidthForPosition === null) return null; + if (viewState.expandedNaturalWidth === null) return null; const shouldProjectExpandedWidth = - (popoverViewState === "expanded-keyhole" && expandedWidthSourceForPosition === "expanded-keyhole") || - (popoverViewState === "expanded-page" && - (expandedWidthSourceForPosition === "expanded-page" || - expandedWidthSourceForPosition === "expanded-keyhole")); + (viewState.current === "expanded-keyhole" && viewState.expandedWidthSource === "expanded-keyhole") || + (viewState.current === "expanded-page" && + (viewState.expandedWidthSource === "expanded-page" || viewState.expandedWidthSource === "expanded-keyhole")); if (shouldProjectExpandedWidth) { - return getExpandedPopoverWidthPx(expandedNaturalWidthForPosition, viewportWidth); + return getExpandedPopoverWidthPx(viewState.expandedNaturalWidth, viewportWidth); } return null; }, [ isHovering, - popoverViewState, + viewState.current, projectedSummaryKeyholeWidth, - expandedNaturalWidthForPosition, - expandedWidthSourceForPosition, + viewState.expandedNaturalWidth, + viewState.expandedWidthSource, ]); const popoverAlignOffset = usePopoverAlignOffset( isHovering, - popoverViewState, + viewState.current, triggerRef, popoverContentRef, projectedPopoverWidthPx, @@ -757,7 +661,7 @@ export const CitationComponent = forwardRef getCitationKey(citation), [citation]); const citationInstanceId = useMemo(() => generateCitationInstanceId(citationKey), [citationKey]); @@ -898,10 +802,10 @@ export const CitationComponent = forwardRef { if (!open && !isAnyOverlayOpenRef.current) { - if (popoverViewStateRef.current !== "summary") return; + if (viewState.ref.current !== "summary") return; closePopover(); } }, [closePopover], ); - const handlePopoverEscapeKeyDown = useCallback( - (e: KeyboardEvent) => { - e.preventDefault(); - if (escapeInterceptRef.current) { - escapeInterceptRef.current(); - return; - } - const vs = popoverViewStateRef.current; - if (vs === "summary") { - closePopover(); - } else if (vs === "expanded-page") { - const prev = prevBeforeExpandedPageRef.current; - setViewStateWithHaptics(prev); - if (prev === "summary") setCustomExpandedSrc(null); - } else { - setViewStateWithHaptics("summary"); - } - }, - [closePopover, setViewStateWithHaptics], - ); - const handlePopoverBackdropClick = useCallback( (e: React.MouseEvent) => { if (isViewTransitioning()) return; @@ -1364,7 +1247,7 @@ export const CitationComponent = forwardRef{fallbackDisplay}; } @@ -1494,14 +1377,14 @@ export const CitationComponent = forwardRef ); @@ -1533,14 +1416,15 @@ export const CitationComponent = forwardRef + 🌐 ); @@ -1692,14 +1576,14 @@ const ExternalLinkButton = ({ type="button" onClick={handleExternalLinkClick} className={cn( - "inline-flex items-center justify-center w-3.5 h-3.5 ml-1 transition-all", + "relative inline-flex items-center justify-center w-6 h-6 -ml-0.5 transition-all", "text-slate-400 group-hover:text-blue-500 dark:text-slate-500 dark:group-hover:text-blue-400", !alwaysVisible && "opacity-30 group-hover:opacity-100 group-focus-within:opacity-100", )} aria-label={ariaLabel} title={title} > - + ); }; @@ -2045,7 +1929,7 @@ export const UrlCitationComponent = forwardRef {showFavicon && } {showFavicon && } @@ -2130,7 +2014,7 @@ export const UrlCitationComponent = forwardRef {showFavicon && } {displayText} @@ -2166,7 +2050,7 @@ export const UrlCitationComponent = forwardRef [{showFavicon && } {displayText} - {indicator} + {/* aria-hidden: the button's aria-label already describes status; the live region + (statusDescId) announces changes. Including role="img" aria-labels here as + "visible text" causes label-content-name-mismatch (WCAG 2.5.3). */} + ); } @@ -146,7 +149,7 @@ export const CitationContentDisplay = ({ // Priority chain: spinner > miss > partial > verified > neutral default let footnoteStatusClasses: string; if (shouldShowSpinner) { - footnoteStatusClasses = "text-slate-400 dark:text-slate-500"; + footnoteStatusClasses = "text-slate-500 dark:text-slate-400"; } else if (isMiss) { footnoteStatusClasses = "text-red-500 dark:text-red-400"; } else if (isPartialMatch) { diff --git a/src/react/CitationDrawerTrigger.tsx b/src/react/CitationDrawerTrigger.tsx index 05a5d818..c0cf88eb 100644 --- a/src/react/CitationDrawerTrigger.tsx +++ b/src/react/CitationDrawerTrigger.tsx @@ -284,7 +284,7 @@ const PRIORITY_DOT_TEXT: Record = { 4: "text-red-600 dark:text-red-400", 3: "text-amber-600 dark:text-amber-400", 2: "text-dc-subtle-foreground", - 1: "text-green-600 dark:text-green-400", + 1: "text-green-700 dark:text-green-400", }; export function StackedStatusIcons({ diff --git a/src/react/CitationStatusIndicator.tsx b/src/react/CitationStatusIndicator.tsx index cbdff7a7..da444942 100644 --- a/src/react/CitationStatusIndicator.tsx +++ b/src/react/CitationStatusIndicator.tsx @@ -186,7 +186,7 @@ export const CitationStatusIndicator = ({ ? "text-red-500 dark:text-red-400" : isOpen ? "text-white dark:text-slate-900" - : "text-slate-400 dark:text-slate-500"; + : "text-slate-500 dark:text-slate-400"; // Pill background: miss → red tint, open → solid dark/light (inverted), default → subtle slate. const pillBgClass = isMiss @@ -201,7 +201,7 @@ export const CitationStatusIndicator = ({ className={cn( "inline-flex items-center justify-center relative ml-0.5 top-[0.05em] [text-decoration:none] rounded-full", pillBgClass, - "text-slate-400 dark:text-slate-500", + "text-slate-500 dark:text-slate-400", )} style={CARET_PILL_STYLE} data-dc-indicator="pending" diff --git a/src/react/CitationVariants.tsx b/src/react/CitationVariants.tsx index 57ad137b..b4b41d83 100644 --- a/src/react/CitationVariants.tsx +++ b/src/react/CitationVariants.tsx @@ -100,7 +100,7 @@ function getStatusToneClass(status: CitationStatus, defaultClass: string): strin if (status.isPartialMatch) return "text-amber-500 dark:text-amber-400"; if (status.isMiss) return "text-red-500 dark:text-red-400"; if (status.isVerified) return "text-green-600 dark:text-green-500"; - if (status.isPending) return "text-slate-400 dark:text-slate-500"; + if (status.isPending) return "text-slate-500 dark:text-slate-400"; return defaultClass || "text-slate-600 dark:text-slate-400"; } diff --git a/src/react/DefaultPopoverContent.tsx b/src/react/DefaultPopoverContent.tsx index a8c993cc..f383fd61 100644 --- a/src/react/DefaultPopoverContent.tsx +++ b/src/react/DefaultPopoverContent.tsx @@ -173,18 +173,18 @@ function PopoverSnippetZone({ snippets }: { snippets: MatchSnippet[] }) { key={`snippet-${snippet.matchStart}-${snippet.matchEnd}-${snippet.page ?? idx}`} className="text-xs text-slate-600 dark:text-slate-300 font-mono leading-relaxed" > - {before && ...{before}} + {before && ...{before}} {match} - {after && {after}...} + {after && {after}...} {snippet.page != null && ( - + ({t("location.page", { pageNumber: snippet.page })}) )} {!snippet.isProximate && ( - + {t("evidence.differentSection")} )} @@ -192,7 +192,7 @@ function PopoverSnippetZone({ snippets }: { snippets: MatchSnippet[] }) { ); })} {snippets.length > 3 && ( -
+
{t("evidence.andMore", { count: snippets.length - 3 })}
)} @@ -677,7 +677,7 @@ function PopoverFallbackView({ +
{showToggle && ( @@ -871,18 +871,18 @@ function MatchSnippetDisplay({ snippet }: { snippet: import("./searchSummaryUtil return (
- {before && ...{before}} + {before && ...{before}} {match} - {after && {after}...} + {after && {after}...} {snippet.page != null && ( - + ({t("location.page", { pageNumber: snippet.page })}) )} {!snippet.isProximate && ( - + {t("evidence.differentSection")} )} @@ -1116,12 +1116,18 @@ export function EvidenceTray({ willChange: searchLogStage === "steady" ? undefined : "transform, padding-top, max-height, opacity", }; }, [searchLogContentHeight, searchLogStage, prefersReducedMotion, showSearchLog]); - const searchCount = useMemo( + const searchNarrative = useMemo( () => (isMiss || isPartialMatch) && searchAttempts.length > 0 - ? groupSearchAttemptsForNotFound(searchAttempts).length - : 0, - [isMiss, isPartialMatch, searchAttempts], + ? buildSearchNarrative( + searchAttempts, + verification?.status ?? "not_found", + verification?.citation?.type === "document" ? verification.citation.pageNumber : undefined, + verification?.citation?.type === "document" ? verification.citation.lineIds?.[0] : undefined, + t, + ) + : null, + [isMiss, isPartialMatch, searchAttempts, verification?.status, verification?.citation, t], ); // Footer element — shared across top/bottom placement @@ -1131,7 +1137,7 @@ export function EvidenceTray({ onPageClick={onExpand ? handlePageExpand : undefined} pageNumberForCta={pageNumberForCta} pageCtaLabel={pageCtaLabel} - searchCount={isMiss || isPartialMatch ? searchCount : undefined} + searchCount={isMiss || isPartialMatch ? searchNarrative?.groupedAttemptCount : undefined} isSearchLogOpen={showSearchLog} onToggleSearchLog={isMiss || isPartialMatch ? () => setShowSearchLog(prev => !prev) : undefined} /> @@ -1178,16 +1184,9 @@ export function EvidenceTray({ className="max-h-[min(44dvh,420px)] overflow-y-auto overscroll-contain" > setShowSearchLog(false)} />
@@ -1239,7 +1238,9 @@ export function EvidenceTray({ )} aria-label={onImageClick ? t("action.viewImage") : t("action.expandFullPage")} > - {content} + {/* aria-hidden: interior is decorative — the button's aria-label describes the action. + This also avoids nested interactive elements (footer CTA) inside a role="button". */} +
) : ( /* Informational: non-clickable display */ diff --git a/src/react/HighlightedPhrase.tsx b/src/react/HighlightedPhrase.tsx index 6c60596b..55ddef9b 100644 --- a/src/react/HighlightedPhrase.tsx +++ b/src/react/HighlightedPhrase.tsx @@ -27,7 +27,9 @@ export function HighlightedPhrase({ return ( {fullPhrase.slice(0, idx)} - {anchorText} + + {anchorText} + {fullPhrase.slice(idx + anchorText.length)} ); diff --git a/src/react/SplitDiffDisplay.tsx b/src/react/SplitDiffDisplay.tsx index 0de37def..f5a0f8d8 100644 --- a/src/react/SplitDiffDisplay.tsx +++ b/src/react/SplitDiffDisplay.tsx @@ -213,7 +213,7 @@ const SplitView: React.FC = memo( {/* Found row */}
- + {t("diff.foundLabel")} diff --git a/src/react/VerificationLog.tsx b/src/react/VerificationLog.tsx index 06d84323..5111350b 100644 --- a/src/react/VerificationLog.tsx +++ b/src/react/VerificationLog.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useMemo, useState } from "react"; import type { Citation } from "../types/citation.js"; import { isUrlCitation } from "../types/citation.js"; -import type { SearchAttempt, SearchMethod, SearchStatus } from "../types/search.js"; +import type { SearchAttempt, SearchStatus } from "../types/search.js"; import type { Verification } from "../types/verification.js"; import { isDomainMatch } from "../utils/urlSafety.js"; import { UrlCitationComponent } from "./Citation.js"; @@ -15,7 +15,7 @@ import { TRUSTED_IMAGE_HOSTS, } from "./constants.js"; import { formatCaptureDate } from "./dateUtils.js"; -import { type MessageKey, type TranslateFunction, tPlural, useLocale, useTranslation } from "./i18n.js"; +import { type TranslateFunction, tPlural, useLocale, useTranslation } from "./i18n.js"; import { CheckIcon, ChevronRightIcon, @@ -27,20 +27,17 @@ import { XCircleIcon, XIcon, } from "./icons.js"; -import { groupSearchAttempts, groupSearchAttemptsForNotFound } from "./searchAttemptGrouping.js"; +import { + buildSearchNarrative, + getStatusColorScheme, + getStatusHeaderText, + type NarrativeRow, + type SearchNarrative, +} from "./searchNarrative.js"; import type { IndicatorVariant, UrlFetchStatus } from "./types.js"; import { sanitizeUrl } from "./urlUtils.js"; import { cn, isImageSource } from "./utils.js"; -/** - * Statuses that show only the successful hit (not the full search trail). - * Everything else — miss, partial, and transient (loading/pending) — shows all attempts. - */ -const SHOW_ONLY_HIT_STATUSES: ReadonlySet = new Set([ - "found", - "found_phrase_missed_anchor_text", -]); - // ============================================================================= // CONSTANTS // ============================================================================= @@ -51,57 +48,17 @@ const MAX_QUOTE_BOX_LENGTH = 150; /** Maximum length for anchor text preview in headers */ const MAX_ANCHOR_TEXT_PREVIEW_LENGTH = 50; -/** Maximum length for phrase display in search attempt rows */ -const MAX_PHRASE_DISPLAY_LENGTH = 60; -const TRUNCATED_PHRASE_PREFIX_LENGTH = 42; -const TRUNCATED_PHRASE_SUFFIX_LENGTH = 14; - -/** Truncate a search phrase for display, showing "(empty)" for blank input. */ -function truncatePhrase(raw: string | undefined | null, t: TranslateFunction): string { - const phrase = raw ?? ""; - if (phrase.length === 0) return t("search.empty"); - if (phrase.length <= MAX_PHRASE_DISPLAY_LENGTH) return phrase; - const prefix = phrase.slice(0, TRUNCATED_PHRASE_PREFIX_LENGTH); - const suffix = phrase.slice(-TRUNCATED_PHRASE_SUFFIX_LENGTH); - return `${prefix}...${suffix}`; -} - /** Maximum length for URL display in popover header */ const MAX_URL_DISPLAY_LENGTH = 45; /** Icon color classes by status - defined outside component to avoid recreation on every render */ const ICON_COLOR_CLASSES = { - green: "text-green-600 dark:text-green-400", + green: "text-green-700 dark:text-green-400", amber: "text-amber-500 dark:text-amber-400", red: "text-red-500 dark:text-red-400", gray: "text-dc-pending", } as const; -const METHOD_KEY_MAP: Record = { - exact_line_match: "search.method.exactLineMatch", - line_with_buffer: "search.method.lineWithBuffer", - expanded_line_buffer: "search.method.expandedLineBuffer", - current_page: "search.method.currentPage", - anchor_text_fallback: "search.method.anchorTextFallback", - adjacent_pages: "search.method.adjacentPages", - expanded_window: "search.method.expandedWindow", - regex_search: "search.method.regexSearch", - first_word_fallback: "search.method.firstWordFallback", - first_half_fallback: "search.method.firstHalfFallback", - last_half_fallback: "search.method.lastHalfFallback", - first_quarter_fallback: "search.method.firstQuarterFallback", - second_quarter_fallback: "search.method.secondQuarterFallback", - third_quarter_fallback: "search.method.thirdQuarterFallback", - fourth_quarter_fallback: "search.method.fourthQuarterFallback", - longest_word_fallback: "search.method.longestWordFallback", - custom_phrase_fallback: "search.method.customPhraseFallback", - keyspan_fallback: "search.method.keyspanFallback", -}; - -function getMethodDisplayName(method: SearchMethod, t: TranslateFunction): string { - return t(METHOD_KEY_MAP[method]); -} - const HEADER_DOWNLOAD_BUTTON_BASE_CLASSES = "shrink-0 size-8 flex items-center justify-center cursor-pointer text-dc-pending hover:text-blue-500 dark:hover:text-blue-400 transition-[opacity,color] duration-120"; const HEADER_DOWNLOAD_BUTTON_REVEAL_CLASSES = @@ -363,9 +320,9 @@ interface PagePillProps { /** Page pill color classes by status */ const PAGE_PILL_COLORS = { - green: "bg-dc-muted text-dc-muted-foreground border-dc-border", - amber: "bg-dc-muted text-dc-muted-foreground border-dc-border", - red: "bg-dc-muted text-dc-muted-foreground border-dc-border", + green: "bg-dc-muted text-zinc-600 dark:text-zinc-300 border-dc-border", + amber: "bg-dc-muted text-zinc-600 dark:text-zinc-300 border-dc-border", + red: "bg-dc-muted text-zinc-600 dark:text-zinc-300 border-dc-border", gray: "bg-dc-muted text-dc-subtle-foreground border-dc-border", } as const; @@ -673,62 +630,6 @@ export interface QuoteBoxProps { maxLength?: number; } -// ============================================================================= -// UTILITY FUNCTIONS -// ============================================================================= - -/** - * Get the color scheme based on status. - */ -// biome-ignore lint/style/useComponentExportOnlyModules: Utility function used by EvidenceTray for PagePill colorScheme -export function getStatusColorScheme(status?: SearchStatus | null): "green" | "amber" | "red" | "gray" { - if (!status) return "gray"; - - switch (status) { - case "found": - case "found_anchor_text_only": - case "found_phrase_missed_anchor_text": - return "green"; - case "found_on_other_page": - case "found_on_other_line": - case "partial_text_found": - case "first_word_found": - return "amber"; - case "not_found": - return "red"; - default: - return "gray"; - } -} - -/** - * Get the header text based on status. - */ -function getStatusHeaderText(status: SearchStatus | null | undefined, t: TranslateFunction): string { - if (!status) return t("status.verifying"); - - switch (status) { - case "found": - case "found_anchor_text_only": - case "found_phrase_missed_anchor_text": - return t("status.verified"); - case "found_on_other_page": - return t("message.foundOnDifferentPage"); - case "found_on_other_line": - return t("message.foundOnDifferentLine"); - case "partial_text_found": - case "first_word_found": - return t("status.partialMatch"); - case "not_found": - return t("status.notFound"); - case "pending": - case "loading": - return t("status.verifying"); - default: - return ""; - } -} - // ============================================================================= // PAGE BADGE COMPONENT // ============================================================================= @@ -976,90 +877,23 @@ export function QuotedText({ children, className, mono = false }: QuotedTextProp // ============================================================================= interface VerificationLogSummaryProps { + narrative: SearchNarrative; status?: SearchStatus | null; - searchAttempts: SearchAttempt[]; - expectedPage?: number; - expectedLine?: number; - foundPage?: number; - foundLine?: number; isExpanded: boolean; onToggle: () => void; verifiedAt?: Date | string | null; } -/** - * Get a human-readable outcome summary for the collapsed state. - * Shows what kind of match was found (or that nothing was found). - */ -function getOutcomeSummary( - status: SearchStatus | null | undefined, - searchAttempts: SearchAttempt[], - t: TranslateFunction, -): string { - // Early return for not_found - no need to search for successful attempt - if (!status || status === "not_found") { - const count = groupSearchAttemptsForNotFound(searchAttempts).length; - return tPlural(t, "verification.attemptsTried", count, { count }); - } - - // Only search for successful attempt when we know something was found - const successfulAttempt = searchAttempts.find(a => a.success); - - // For found states, describe the match type - if (successfulAttempt?.matchedVariation) { - switch (successfulAttempt.matchedVariation) { - case "exact_full_phrase": - return t("outcome.exactMatch"); - case "normalized_full_phrase": - return t("outcome.normalizedMatch"); - case "exact_anchor_text": - case "normalized_anchor_text": - return t("outcome.anchorTextMatch"); - case "partial_full_phrase": - case "partial_anchor_text": - return t("outcome.partialMatch"); - case "first_word_only": - return t("outcome.firstWordMatch"); - default: - return t("outcome.matchFound"); - } - } - - // Fallback based on status - switch (status) { - case "found": - case "found_phrase_missed_anchor_text": - return t("outcome.exactMatch"); - case "found_anchor_text_only": - return t("outcome.anchorTextMatch"); - case "found_on_other_page": - case "found_on_other_line": - return t("outcome.foundDifferentLocation"); - case "partial_text_found": - return t("outcome.partialMatch"); - case "first_word_found": - return t("outcome.firstWordMatch"); - default: - return t("outcome.matchFound"); - } -} - /** * Clickable summary footer — demoted text link for audit details. * Uses unified "Verification details" label across all states. * The parenthetical changes based on status: "(Exact match)" vs "(16 attempts)". */ -function VerificationLogSummary({ - status, - searchAttempts, - isExpanded, - onToggle, - verifiedAt, -}: VerificationLogSummaryProps) { +function VerificationLogSummary({ narrative, status, isExpanded, onToggle, verifiedAt }: VerificationLogSummaryProps) { const t = useTranslation(); const locale = useLocale(); const isMiss = status === "not_found"; - const outcomeSummary = getOutcomeSummary(status, searchAttempts, t); + const outcomeSummary = narrative.outcomeSummary; // Format the verified date for display const formatted = formatCaptureDate(verifiedAt, { locale }); @@ -1089,7 +923,7 @@ function VerificationLogSummary({
{dateStr && ( 0; - const hasLine = line != null && line > 0; - if (hasPage && hasLine) return t("location.pageLine", { pageNumber: page, lineNumber: line }); - if (hasPage) return t("location.page", { pageNumber: page }); - if (hasLine) return t("location.line", { lineNumber: line }); - return t("location.unknown"); -} - -interface AttemptTableRowProps { - text: string; - locationText: string; - duplicateCount: number; - success: boolean; - isUnexpectedHit: boolean; - /** Skip reason or method note — shown as a tooltip on hover. */ - note?: string; -} - -/** Compact row used by the attempts table for not-found and partial states. */ -function AttemptTableRow({ text, locationText, duplicateCount, success, isUnexpectedHit, note }: AttemptTableRowProps) { - const t = useTranslation(); - const isTruncated = (text ?? "").length > MAX_PHRASE_DISPLAY_LENGTH; - const showLocationMultiplicity = success && isUnexpectedHit && duplicateCount > 1; - const locationMultiplicityLabel = showLocationMultiplicity - ? tPlural(t, "location.matchingLocations", duplicateCount, { count: duplicateCount }) - : null; - - // success here means we successfully found a partial match, exact match does not need attempt details as the result is self evident - return ( -
- - {text} - - - {locationText} - {locationMultiplicityLabel ? ` · ${locationMultiplicityLabel}` : ""} - -
- ); -} +/** Maximum length for phrase display — used for tooltip truncation detection. */ +const MAX_PHRASE_DISPLAY_LENGTH = 60; /** * "Looking for" section showing original citation text being searched. @@ -1209,40 +971,109 @@ export function LookingForSection({ anchorText, fullPhrase }: { anchorText?: str ); } +/** Renders a single NarrativeRow as a compact timeline entry. */ +function NarrativeRowRenderer({ row }: { row: NarrativeRow }) { + const t = useTranslation(); + const isTruncated = row.phraseFull.length > MAX_PHRASE_DISPLAY_LENGTH; + + switch (row.kind) { + case "success": { + // Card layout for the single "hit only" view (showAllRows=false) + if (row.duplicateCount === 1 && !row.isUnexpectedHit && row.methodLabel) { + return ( +
+
+
+
+ + + + + {row.phraseDisplay} + +
+
+ {row.methodLabel} + {row.locationLabel && {row.locationLabel}} +
+
+
+
+ ); + } + // Compact row for "show all" mode — amber border + const showLocationMultiplicity = row.isUnexpectedHit && row.duplicateCount > 1; + const locationMultiplicityLabel = showLocationMultiplicity + ? tPlural(t, "location.matchingLocations", row.duplicateCount, { count: row.duplicateCount }) + : null; + return ( +
+ + {row.phraseDisplay} + + + {row.locationLabel} + {locationMultiplicityLabel ? ` · ${locationMultiplicityLabel}` : ""} + +
+ ); + } + case "failure": + return ( +
+ + {row.phraseDisplay} + + + {row.locationLabel} + +
+ ); + case "collapsed_failure": + return ( +
+ + {row.phraseDisplay} + + + {row.locationLabel} + +
+ ); + } +} + /** - * Audit-focused search display. - * - For exact matches ("found", "found_phrase_missed_anchor_text"): Shows only the successful match details - * - For all other statuses (not_found, partial): Shows all search attempts to help debug + * Renders pre-computed narrative rows as the audit timeline. + * Replaces AuditSearchDisplay — all interpretation logic is in buildSearchNarrative(). */ -function AuditSearchDisplay({ - searchAttempts, +function NarrativeRowsDisplay({ + narrative, fullPhrase, anchorText, - expectedPage, - expectedLine, - status, -}: AuditSearchDisplayProps) { +}: { + narrative: SearchNarrative; + fullPhrase?: string; + anchorText?: string; +}) { const t = useTranslation(); - const groupedAttempts = useMemo( - () => - status === "not_found" ? groupSearchAttemptsForNotFound(searchAttempts) : groupSearchAttempts(searchAttempts), - [searchAttempts, status], - ); - // Show all searches unless the status is a confirmed exact match. - // Transient statuses (loading, pending) show partial attempts as they arrive. - // Null/undefined status is treated as "unknown" — show all searches. - const showAll = status == null || !SHOW_ONLY_HIT_STATUSES.has(status); - const successfulAttempt = useMemo( - () => groupedAttempts.find(group => group.attempt.success)?.attempt, - [groupedAttempts], - ); - // If no search attempts, fall back to citation data - if (groupedAttempts.length === 0) { + // If no rows, fall back to citation data + if (narrative.rows.length === 0) { const fallbackPhrases = [fullPhrase, anchorText].filter((p): p is string => Boolean(p)); if (fallbackPhrases.length === 0) return null; - // Display fallback as simple list return (
@@ -1266,97 +1097,11 @@ function AuditSearchDisplay({ ); } - // For exact matches: show only the successful match details - if (!showAll && successfulAttempt) { - const displayPhrase = truncatePhrase(successfulAttempt.searchPhrase, t); - - const methodName = getMethodDisplayName(successfulAttempt.method, t); - const locationText = successfulAttempt.foundLocation - ? successfulAttempt.foundLocation.line - ? t("location.pageLineFull", { - pageNumber: successfulAttempt.foundLocation.page, - lineNumber: successfulAttempt.foundLocation.line, - }) - : t("location.pageFull", { pageNumber: successfulAttempt.foundLocation.page }) - : successfulAttempt.pageSearched != null - ? t("location.pageFull", { pageNumber: successfulAttempt.pageSearched }) - : ""; - - return ( -
-
-
- {/* What was matched */} -
- - - - - {displayPhrase} - -
- {/* Where it was found */} -
- {methodName} - {locationText && {locationText}} -
-
-
-
- ); - } - - const attemptRows = groupedAttempts.map(group => { - const { attempt, key, duplicateCount } = group; - const foundPage = attempt.foundLocation?.page ?? attempt.pageSearched; - const foundLine = attempt.foundLocation?.line ?? getFirstLine(attempt.lineSearched); - - // Use page range when the not_found grouping collapsed multiple pages - const locationText = - group.pageRange && group.pageRange.min !== group.pageRange.max - ? t("location.pageRange", { startPage: group.pageRange.min, endPage: group.pageRange.max }) - : formatLocationLabel(foundPage, foundLine, t); - - const unexpectedPage = - attempt.success && - expectedPage != null && - expectedPage > 0 && - foundPage != null && - foundPage > 0 && - foundPage !== expectedPage; - const unexpectedLine = - attempt.success && - expectedLine != null && - expectedLine > 0 && - foundLine != null && - foundLine > 0 && - foundLine !== expectedLine; - - return { - key, - text: truncatePhrase(attempt.searchPhrase, t), - success: attempt.success, - isUnexpectedHit: unexpectedPage || unexpectedLine, - locationText, - duplicateCount, - note: attempt.note, - }; - }); - - const orderedRows = [...attemptRows.filter(row => !row.success), ...attemptRows.filter(row => row.success)]; - + // Render all rows — NarrativeRowRenderer handles card vs compact layout per row.kind return ( -
- {orderedRows.map(row => ( - +
+ {narrative.rows.map(row => ( + ))}
); @@ -1367,43 +1112,27 @@ function AuditSearchDisplay({ // ============================================================================= interface VerificationLogTimelineProps { - searchAttempts: SearchAttempt[]; + narrative: SearchNarrative; fullPhrase?: string; anchorText?: string; - expectedPage?: number; - expectedLine?: number; - status?: SearchStatus | null; /** Callback to collapse the expanded details. Skipped when the user is selecting text. */ onCollapse?: () => void; } /** * Scrollable timeline showing search attempts. - * - For exact matches ("found", "found_phrase_missed_anchor_text"): Shows only the successful match - * - For not_found and all partial statuses: Shows all search attempts with clear count + * Renders pre-computed NarrativeRow[] — no interpretation logic. * * Clicking the area collapses it (unless the user is selecting text). */ export function VerificationLogTimeline({ - searchAttempts, + narrative, fullPhrase, anchorText, - expectedPage, - expectedLine, - status, onCollapse, }: VerificationLogTimelineProps) { const t = useTranslation(); - const content = ( - - ); + const content = ; if (!onCollapse) { return
{content}
; @@ -1435,14 +1164,17 @@ export function VerificationLogTimeline({ /** * Collapsible verification log showing search attempt timeline. * Displays a summary header that can be clicked to expand the full log. + * + * Internally builds a SearchNarrative (via buildSearchNarrative) once per render + * and passes it to child components — all interpretation logic is centralized. */ export function VerificationLog({ searchAttempts, status, expectedPage, expectedLine, - foundPage, - foundLine, + foundPage: _foundPage, // kept for API compat; narrative derives from attempt.foundLocation + foundLine: _foundLine, // kept for API compat; narrative derives from attempt.foundLocation isExpanded: controlledIsExpanded, onExpandChange, fullPhrase, @@ -1450,6 +1182,7 @@ export function VerificationLog({ ambiguity, verifiedAt, }: VerificationLogProps) { + const t = useTranslation(); const [internalIsExpanded, setInternalIsExpanded] = useState(false); // Use controlled state if provided, otherwise internal @@ -1462,41 +1195,33 @@ export function VerificationLog({ } }; - // Memoize the successful attempt lookup - const successfulAttempt = useMemo(() => searchAttempts.find(a => a.success), [searchAttempts]); + // Build the narrative once — all interpretation logic is centralized here + const narrative = useMemo( + () => buildSearchNarrative(searchAttempts, status, expectedPage, expectedLine, t), + [searchAttempts, status, expectedPage, expectedLine, t], + ); // Don't render if no attempts if (!searchAttempts || searchAttempts.length === 0) { return null; } - // Derive found location from successful attempt if not provided - const derivedFoundPage = foundPage ?? successfulAttempt?.foundLocation?.page ?? successfulAttempt?.pageSearched; - const derivedFoundLine = foundLine ?? successfulAttempt?.foundLocation?.line; - return (
{/* Ambiguity warning when multiple occurrences exist */} {ambiguity && } setIsExpanded(!isExpanded)} verifiedAt={verifiedAt} /> {isExpanded && ( setIsExpanded(false)} /> )} diff --git a/src/react/hooks/usePopoverViewState.ts b/src/react/hooks/usePopoverViewState.ts new file mode 100644 index 00000000..79742854 --- /dev/null +++ b/src/react/hooks/usePopoverViewState.ts @@ -0,0 +1,197 @@ +// React Compiler opt-out: viewStateRef is mutated in transition/onEscapeKeyDown +// callbacks and read in useLayoutEffect — the compiler cannot safely memoize +// across this boundary. +// "use no memo" — React Compiler opt-out (would be a directive if compiler were active). + +import type { MutableRefObject, RefObject } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { PopoverViewState } from "../DefaultPopoverContent.js"; +import { triggerHaptic } from "../haptics.js"; +import { acquireScrollLock, releaseScrollLock } from "../scrollLock.js"; +import { startEvidencePageExpandTransition, startEvidenceViewTransition } from "../viewTransition.js"; + +export interface UsePopoverViewStateConfig { + isOpen: boolean; + popoverContentRef: RefObject; + experimentalHaptics?: boolean; + isMobile?: boolean; + prefersReducedMotion?: boolean; + /** Called when Escape at summary level should dismiss the popover */ + onDismiss?: () => void; + /** Called when any transition collapses back to summary */ + onCollapseToSummary?: () => void; +} + +export interface PopoverViewStateHandle { + /** Current view state for rendering */ + current: PopoverViewState; + /** Ref in sync with current — safe in addEventListener handlers */ + ref: RefObject; + /** Transition to a new state (handles haptics, VT, scroll lock, history) */ + transition: (next: PopoverViewState) => void; + /** Escape key handler — wire to PopoverContent.onEscapeKeyDown */ + onEscapeKeyDown: (e: KeyboardEvent) => void; + /** Ref for child components to register escape intercepts */ + escapeInterceptRef: MutableRefObject<(() => void) | null>; + /** Tracks which state preceded expanded-page (for back-nav rendering) */ + prevBeforeExpandedPageRef: RefObject<"summary" | "expanded-keyhole">; + /** Expanded image natural width (null when in summary) */ + expandedNaturalWidth: number | null; + /** Which expanded state reported the width */ + expandedWidthSource: "expanded-keyhole" | "expanded-page" | null; + /** Width change handler — wire to DefaultPopoverContent.onExpandedWidthChange */ + onExpandedWidthChange: (width: number | null, source?: "expanded-keyhole" | "expanded-page" | null) => void; + /** Reset view state to summary and clear width/expanded state (for popover open). + * NOTE: Does NOT invoke onCollapseToSummary — callers must handle side effects separately. */ + resetToSummary: () => void; +} + +const ORDER: Record = { summary: 0, "expanded-keyhole": 1, "expanded-page": 2 }; + +export function usePopoverViewState(config: UsePopoverViewStateConfig): PopoverViewStateHandle { + const { + isOpen, + popoverContentRef, + experimentalHaptics, + isMobile, + prefersReducedMotion, + onDismiss, + onCollapseToSummary, + } = config; + + const [viewState, setViewState] = useState("summary"); + const [expandedNaturalWidth, setExpandedNaturalWidth] = useState(null); + const [expandedWidthSource, setExpandedWidthSource] = useState<"expanded-keyhole" | "expanded-page" | null>(null); + + const prevBeforeExpandedPageRef = useRef<"summary" | "expanded-keyhole">("summary"); + const escapeInterceptRef = useRef<(() => void) | null>(null); + + // Ref kept in sync so addEventListener handlers read the latest value. + // useLayoutEffect ensures the ref is updated before any synchronous reads + // in the same tick — React 18 automatic batching can call transition() + // twice in one handler, and useEffect would leave the ref stale until after paint. + const viewStateRef = useRef("summary"); + useLayoutEffect(() => { + viewStateRef.current = viewState; + }, [viewState]); + + // Keep callback refs in sync to avoid stale closures + const onDismissRef = useRef(onDismiss); + useLayoutEffect(() => { + onDismissRef.current = onDismiss; + }, [onDismiss]); + + const onCollapseToSummaryRef = useRef(onCollapseToSummary); + useLayoutEffect(() => { + onCollapseToSummaryRef.current = onCollapseToSummary; + }, [onCollapseToSummary]); + + const handleExpandedWidthChange = useCallback( + (width: number | null, sourceOverride?: "expanded-keyhole" | "expanded-page" | null) => { + const source = sourceOverride ?? viewStateRef.current; + if (source !== "expanded-keyhole" && source !== "expanded-page") { + setExpandedNaturalWidth(null); + setExpandedWidthSource(null); + return; + } + setExpandedNaturalWidth(width); + setExpandedWidthSource(source); + }, + [], + ); + + const transition = useCallback( + (newState: PopoverViewState) => { + const prev = viewStateRef.current; + if (experimentalHaptics && isMobile) { + const isExpanding = (newState === "expanded-page" || newState === "expanded-keyhole") && prev === "summary"; + const isCollapsing = newState === "summary" && (prev === "expanded-page" || prev === "expanded-keyhole"); + if (isExpanding) triggerHaptic("expand"); + else if (isCollapsing) triggerHaptic("collapse"); + } + // Track which state preceded expanded-page for back-nav + if (newState === "expanded-page" && prev !== "expanded-page") { + prevBeforeExpandedPageRef.current = prev === "expanded-keyhole" ? "expanded-keyhole" : "summary"; + } + // Collapse direction for VT timing + const isCollapse = ORDER[newState] < ORDER[prev]; + const commitViewState = () => { + if (newState === "summary") { + setExpandedNaturalWidth(null); + setExpandedWidthSource(null); + onCollapseToSummaryRef.current?.(); + } + setViewState(newState); + }; + const isPageExpand = !isCollapse && newState === "expanded-page"; + if (isPageExpand) { + startEvidencePageExpandTransition(commitViewState, { + root: popoverContentRef.current, + skipAnimation: prefersReducedMotion, + }); + return; + } + startEvidenceViewTransition(commitViewState, { isCollapse, skipAnimation: prefersReducedMotion }); + }, + [experimentalHaptics, isMobile, prefersReducedMotion, popoverContentRef], + ); + + const onEscapeKeyDown = useCallback( + (e: KeyboardEvent) => { + e.preventDefault(); + if (escapeInterceptRef.current) { + escapeInterceptRef.current(); + return; + } + const vs = viewStateRef.current; + if (vs === "summary") { + onDismissRef.current?.(); + } else if (vs === "expanded-page") { + const prev = prevBeforeExpandedPageRef.current; + transition(prev); + } else { + transition("summary"); + } + }, + [transition], + ); + + // Lock body scroll only for expanded-page (full-viewport) + useEffect(() => { + if (!isOpen) return; + if (viewState !== "expanded-page") return; + acquireScrollLock(); + return () => releaseScrollLock(); + }, [isOpen, viewState]); + + const resetToSummary = useCallback(() => { + setViewState("summary"); + setExpandedNaturalWidth(null); + setExpandedWidthSource(null); + prevBeforeExpandedPageRef.current = "summary"; + }, []); + + return useMemo( + (): PopoverViewStateHandle => ({ + current: viewState, + ref: viewStateRef, + transition, + onEscapeKeyDown, + escapeInterceptRef, + prevBeforeExpandedPageRef, + expandedNaturalWidth, + expandedWidthSource, + onExpandedWidthChange: handleExpandedWidthChange, + resetToSummary, + }), + [ + viewState, + expandedNaturalWidth, + expandedWidthSource, + transition, + onEscapeKeyDown, + handleExpandedWidthChange, + resetToSummary, + ], + ); +} diff --git a/src/react/index.ts b/src/react/index.ts index 1d61144b..c39611dc 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -178,10 +178,23 @@ export { SplitDiffDisplay, type SplitDiffDisplayProps, } from "./SplitDiffDisplay.js"; +// Search Narrative (centralized interpretation of SearchAttempt[] → display-ready data) +export { + buildSearchNarrative, + type CollapsedFailureRow, + type FailureRow, + getStatusColorScheme, + getStatusHeaderText, + type NarrativeOutcome, + type NarrativeRow, + type SearchNarrative, + type SuccessRow, +} from "./searchNarrative.js"; // Search Summary Utilities export { buildIntentSummary, buildSearchSummary, + deriveContextWindow, type IntentSummary, type MatchSnippet, type SearchOutcome, diff --git a/src/react/primitives.tsx b/src/react/primitives.tsx index 12d0b365..eb469c96 100644 --- a/src/react/primitives.tsx +++ b/src/react/primitives.tsx @@ -193,7 +193,7 @@ export const CitationTrigger = forwardRef status.isVerified && !status.isPartialMatch && "text-green-600 dark:text-green-500", status.isPartialMatch && "text-amber-500 dark:text-amber-400", status.isMiss && "text-red-500 dark:text-red-400", - status.isPending && "text-slate-400 dark:text-slate-500", + status.isPending && "text-slate-500 dark:text-slate-400", ); return ( @@ -382,7 +382,7 @@ export const CitationIndicator = forwardRef @@ -455,7 +455,7 @@ export const CitationPage = forwardRef( } return ( - + {prefix} {citation.pageNumber} diff --git a/src/react/searchNarrative.ts b/src/react/searchNarrative.ts new file mode 100644 index 00000000..9a4a05b7 --- /dev/null +++ b/src/react/searchNarrative.ts @@ -0,0 +1,428 @@ +import type { SearchAttempt, SearchMethod, SearchStatus } from "../types/search.js"; +import { defaultTranslator, type MessageKey, type TranslateFunction, tPlural } from "./i18n.js"; +import { groupSearchAttempts, groupSearchAttemptsForNotFound } from "./searchAttemptGrouping.js"; + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * Coarse outcome from the user's perspective. + * Drives the visual theme (color, icon) of every downstream component. + */ +export type NarrativeOutcome = "exact_match" | "partial_match" | "not_found" | "pending"; + +/** + * A single rendered row in the timeline. + * All interpretation has already happened — the renderer just maps this to DOM. + */ +export type NarrativeRow = SuccessRow | FailureRow | CollapsedFailureRow; + +export interface SuccessRow { + kind: "success"; + key: string; + /** Search phrase, already truncated for display. */ + phraseDisplay: string; + /** Full phrase for tooltip. */ + phraseFull: string; + /** e.g. "Exact line match", already translated. */ + methodLabel: string; + /** e.g. "p. 3, line 12", already translated. Null when unknown. */ + locationLabel: string | null; + /** True when found page/line differs from expected. */ + isUnexpectedHit: boolean; + /** Number of method-level retries collapsed into this row. */ + duplicateCount: number; + /** API-generated note (e.g. "rejected: wrong column"). */ + note: string | undefined; +} + +export interface FailureRow { + kind: "failure"; + key: string; + phraseDisplay: string; + phraseFull: string; + locationLabel: string | null; + duplicateCount: number; + note: string | undefined; +} + +export interface CollapsedFailureRow { + kind: "collapsed_failure"; + key: string; + phraseDisplay: string; + phraseFull: string; + locationLabel: string | null; + duplicateCount: number; +} + +/** + * Everything VerificationLog needs to render, pre-computed. + * + * Consumers should render mechanically from this struct. + * `rows` is the direct render array — map it, nothing else. + */ +export interface SearchNarrative { + outcome: NarrativeOutcome; + colorScheme: "green" | "amber" | "red" | "gray"; + /** Pre-translated status label: "Verified", "Partial Match", etc. */ + statusLabel: string; + /** One-liner for the collapsed summary parenthetical. */ + outcomeSummary: string; + /** Pre-computed rows for the timeline. Ordered: failures first, then successes. */ + rows: NarrativeRow[]; + /** Whether to show all rows (true) or just the winning hit (false). */ + showAllRows: boolean; + /** Total raw attempt count. */ + totalAttempts: number; + /** Number of grouped attempts (for not-found/partial display counts). */ + groupedAttemptCount: number; +} + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +const MAX_PHRASE_DISPLAY_LENGTH = 60; +const TRUNCATED_PHRASE_PREFIX_LENGTH = 42; +const TRUNCATED_PHRASE_SUFFIX_LENGTH = 14; + +/** + * Statuses that show only the successful hit (not the full search trail). + */ +const SHOW_ONLY_HIT_STATUSES: ReadonlySet = new Set([ + "found", + "found_phrase_missed_anchor_text", +]); + +const METHOD_KEY_MAP: Record = { + exact_line_match: "search.method.exactLineMatch", + line_with_buffer: "search.method.lineWithBuffer", + expanded_line_buffer: "search.method.expandedLineBuffer", + current_page: "search.method.currentPage", + anchor_text_fallback: "search.method.anchorTextFallback", + adjacent_pages: "search.method.adjacentPages", + expanded_window: "search.method.expandedWindow", + regex_search: "search.method.regexSearch", + first_word_fallback: "search.method.firstWordFallback", + first_half_fallback: "search.method.firstHalfFallback", + last_half_fallback: "search.method.lastHalfFallback", + first_quarter_fallback: "search.method.firstQuarterFallback", + second_quarter_fallback: "search.method.secondQuarterFallback", + third_quarter_fallback: "search.method.thirdQuarterFallback", + fourth_quarter_fallback: "search.method.fourthQuarterFallback", + longest_word_fallback: "search.method.longestWordFallback", + custom_phrase_fallback: "search.method.customPhraseFallback", + keyspan_fallback: "search.method.keyspanFallback", +}; + +// ============================================================================= +// INTERNAL HELPERS +// ============================================================================= + +function truncatePhrase(raw: string | undefined | null, t: TranslateFunction): string { + const phrase = raw ?? ""; + if (phrase.length === 0) return t("search.empty"); + if (phrase.length <= MAX_PHRASE_DISPLAY_LENGTH) return phrase; + const prefix = phrase.slice(0, TRUNCATED_PHRASE_PREFIX_LENGTH); + const suffix = phrase.slice(-TRUNCATED_PHRASE_SUFFIX_LENGTH); + return `${prefix}...${suffix}`; +} + +function getFirstLine(line: number | number[] | undefined): number | undefined { + if (Array.isArray(line)) return line[0]; + return line; +} + +function formatLocationLabel(page: number | undefined, line: number | undefined, t: TranslateFunction): string { + const hasPage = page != null && page > 0; + const hasLine = line != null && line > 0; + if (hasPage && hasLine) return t("location.pageLine", { pageNumber: page, lineNumber: line }); + if (hasPage) return t("location.page", { pageNumber: page }); + if (hasLine) return t("location.line", { lineNumber: line }); + return t("location.unknown"); +} + +export function getStatusColorScheme(status?: SearchStatus | null): "green" | "amber" | "red" | "gray" { + if (!status) return "gray"; + switch (status) { + case "found": + case "found_anchor_text_only": + case "found_phrase_missed_anchor_text": + return "green"; + case "found_on_other_page": + case "found_on_other_line": + case "partial_text_found": + case "first_word_found": + return "amber"; + case "not_found": + return "red"; + default: + return "gray"; + } +} + +export function getStatusHeaderText(status: SearchStatus | null | undefined, t: TranslateFunction): string { + if (!status) return t("status.verifying"); + switch (status) { + case "found": + case "found_anchor_text_only": + case "found_phrase_missed_anchor_text": + return t("status.verified"); + case "found_on_other_page": + return t("message.foundOnDifferentPage"); + case "found_on_other_line": + return t("message.foundOnDifferentLine"); + case "partial_text_found": + case "first_word_found": + return t("status.partialMatch"); + case "not_found": + return t("status.notFound"); + case "pending": + case "loading": + return t("status.verifying"); + default: + return ""; + } +} + +function getOutcomeSummary( + status: SearchStatus | null | undefined, + searchAttempts: SearchAttempt[], + t: TranslateFunction, +): string { + if (!status || status === "not_found") { + const count = groupSearchAttemptsForNotFound(searchAttempts).length; + return tPlural(t, "verification.attemptsTried", count, { count }); + } + + const successfulAttempt = searchAttempts.find(a => a.success); + if (successfulAttempt?.matchedVariation) { + switch (successfulAttempt.matchedVariation) { + case "exact_full_phrase": + return t("outcome.exactMatch"); + case "normalized_full_phrase": + return t("outcome.normalizedMatch"); + case "exact_anchor_text": + case "normalized_anchor_text": + return t("outcome.anchorTextMatch"); + case "partial_full_phrase": + case "partial_anchor_text": + return t("outcome.partialMatch"); + case "first_word_only": + return t("outcome.firstWordMatch"); + default: + return t("outcome.matchFound"); + } + } + + switch (status) { + case "found": + case "found_phrase_missed_anchor_text": + return t("outcome.exactMatch"); + case "found_anchor_text_only": + return t("outcome.anchorTextMatch"); + case "found_on_other_page": + case "found_on_other_line": + return t("outcome.foundDifferentLocation"); + case "partial_text_found": + return t("outcome.partialMatch"); + case "first_word_found": + return t("outcome.firstWordMatch"); + default: + return t("outcome.matchFound"); + } +} + +function deriveOutcome(status: SearchStatus | null | undefined): NarrativeOutcome { + if (!status) return "pending"; + switch (status) { + case "found": + case "found_anchor_text_only": + case "found_phrase_missed_anchor_text": + return "exact_match"; + case "found_on_other_page": + case "found_on_other_line": + case "partial_text_found": + case "first_word_found": + return "partial_match"; + case "not_found": + return "not_found"; + case "pending": + case "loading": + return "pending"; + default: + return "pending"; + } +} + +// ============================================================================= +// ROW BUILDERS +// ============================================================================= + +/** + * Build a single SuccessRow for the exact-match "hit only" view. + */ +function buildSuccessOnlyRow(attempt: SearchAttempt, t: TranslateFunction): SuccessRow { + const locationLabel = attempt.foundLocation + ? attempt.foundLocation.line + ? t("location.pageLineFull", { + pageNumber: attempt.foundLocation.page, + lineNumber: attempt.foundLocation.line, + }) + : t("location.pageFull", { pageNumber: attempt.foundLocation.page }) + : attempt.pageSearched != null + ? t("location.pageFull", { pageNumber: attempt.pageSearched }) + : null; + + return { + kind: "success", + key: "success-hit", + phraseDisplay: truncatePhrase(attempt.searchPhrase, t), + phraseFull: attempt.searchPhrase ?? "", + methodLabel: t(METHOD_KEY_MAP[attempt.method]), + locationLabel, + isUnexpectedHit: false, + duplicateCount: 1, + note: attempt.note, + }; +} + +/** + * Build NarrativeRow[] from grouped attempts for the "show all" view. + */ +function buildAllRows( + searchAttempts: SearchAttempt[], + status: SearchStatus | null | undefined, + expectedPage: number | undefined, + expectedLine: number | undefined, + t: TranslateFunction, +): NarrativeRow[] { + const isNotFound = status === "not_found"; + const grouped = isNotFound ? groupSearchAttemptsForNotFound(searchAttempts) : groupSearchAttempts(searchAttempts); + + const rows: NarrativeRow[] = []; + for (const group of grouped) { + const { attempt, key, duplicateCount } = group; + const foundPage = attempt.foundLocation?.page ?? attempt.pageSearched; + const foundLine = attempt.foundLocation?.line ?? getFirstLine(attempt.lineSearched); + + const locationText = + group.pageRange && group.pageRange.min !== group.pageRange.max + ? t("location.pageRange", { startPage: group.pageRange.min, endPage: group.pageRange.max }) + : formatLocationLabel(foundPage, foundLine, t); + + const unexpectedPage = + attempt.success && + expectedPage != null && + expectedPage > 0 && + foundPage != null && + foundPage > 0 && + foundPage !== expectedPage; + const unexpectedLine = + attempt.success && + expectedLine != null && + expectedLine > 0 && + foundLine != null && + foundLine > 0 && + foundLine !== expectedLine; + const isUnexpectedHit = unexpectedPage || unexpectedLine; + + const phraseDisplay = truncatePhrase(attempt.searchPhrase, t); + const phraseFull = attempt.searchPhrase ?? ""; + + if (isNotFound && group.pageRange) { + rows.push({ + kind: "collapsed_failure", + key, + phraseDisplay, + phraseFull, + locationLabel: locationText, + duplicateCount, + }); + } else if (attempt.success) { + rows.push({ + kind: "success", + key, + phraseDisplay, + phraseFull, + methodLabel: t(METHOD_KEY_MAP[attempt.method]), + locationLabel: locationText, + isUnexpectedHit, + duplicateCount, + note: attempt.note, + }); + } else { + rows.push({ + kind: "failure", + key, + phraseDisplay, + phraseFull, + locationLabel: locationText, + duplicateCount, + note: attempt.note, + }); + } + } + + // Order: failures first, then successes + const failures = rows.filter(r => r.kind !== "success"); + const successes = rows.filter(r => r.kind === "success"); + return [...failures, ...successes]; +} + +// ============================================================================= +// PUBLIC API +// ============================================================================= + +/** + * Build the complete SearchNarrative for a set of search attempts. + * + * Pure function — no side effects, no React dependencies. + * Safe to call in useMemo, server-side, in tests, or in non-React contexts. + * + * @param searchAttempts - The ordered search attempts from verification. + * @param status - The overall verification status. + * @param expectedPage - Expected page from the citation. + * @param expectedLine - Expected line from the citation. + * @param t - Translation function. Defaults to English. + */ +export function buildSearchNarrative( + searchAttempts: SearchAttempt[], + status: SearchStatus | null | undefined, + expectedPage?: number, + expectedLine?: number, + t: TranslateFunction = defaultTranslator, +): SearchNarrative { + const outcome = deriveOutcome(status); + const colorScheme = getStatusColorScheme(status); + const statusLabel = getStatusHeaderText(status, t); + const outcomeSummary = getOutcomeSummary(status, searchAttempts, t); + const showAllRows = status == null || !SHOW_ONLY_HIT_STATUSES.has(status); + const totalAttempts = searchAttempts.length; + + // Build rows + let rows: NarrativeRow[]; + if (!showAllRows) { + // Exact match: find the successful attempt and show only that + const successfulAttempt = searchAttempts.find(a => a.success); + rows = successfulAttempt ? [buildSuccessOnlyRow(successfulAttempt, t)] : []; + } else { + rows = buildAllRows(searchAttempts, status, expectedPage, expectedLine, t); + } + + // Derive from rows.length — buildAllRows produces exactly one row per grouped + // attempt, so this is always consistent with the rendered timeline. + const groupedAttemptCount = showAllRows ? rows.length : 0; + + return { + outcome, + colorScheme, + statusLabel, + outcomeSummary, + rows, + showAllRows, + totalAttempts, + groupedAttemptCount, + }; +} diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-chromium-linux.avif index 6a0f4f3d..62c03380 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-dark-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-dark-chromium-linux.avif index b0344d5e..d5227163 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-dark-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/desktop-showcase-dark-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-chromium-linux.avif index 57c60c0e..e54fc49b 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-dark-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-dark-chromium-linux.avif index 941f98fb..e976af53 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-dark-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/mobile-showcase-dark-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-chromium-linux.avif index 63bbbf1c..a1195101 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-dark-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-dark-chromium-linux.avif index b7ac8fbe..6d5733d5 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-dark-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/popover-showcase-dark-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-chromium-linux.avif index dfb6f403..5ebcfc7f 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-dark-chromium-linux.avif b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-dark-chromium-linux.avif index fafc19b1..01d2d2ab 100644 Binary files a/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-dark-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/visualShowcase.spec.tsx-snapshots/tablet-showcase-dark-chromium-linux.avif differ