Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ lib/**/*
bun.lock

.turbo/
.agents/
.claude/
skills-lock.json

/scratch/
/playwright/.cache/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

</div>

Expand Down
14 changes: 14 additions & 0 deletions docs/agents/animation-transition-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/agents/canonical-exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
169 changes: 169 additions & 0 deletions plans/citation-list-todo.md
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** Page images keyed by attachmentId (passed to popover) */
pageImagesByAttachmentId?: Record<string, PageImage[]>;
/** 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 (
<div className={cn("divide-y divide-dc-border", className)} style={scrollStyle}>
{flatItems.map(flat => (
<CitationListRow key={flat.item.citationKey} flat={flat} ... />
))}
</div>
);
}

function CitationListRow({ flat, indicatorVariant, pageImagesByAttachmentId }) {
const { item, sourceName, sourceFavicon } = flat;
const anchorText = item.citation.anchorText?.toString() || item.citation.fullPhrase || "";

return (
<div className="flex items-center gap-2 px-2 py-1.5 text-sm min-w-0">
{/* Clickable status indicator — opens standard popover */}
<CitationComponent
citation={item.citation}
verification={item.verification}
content="indicator"
indicatorVariant={indicatorVariant}
popoverPosition="top"
pageImagesByAttachmentId={pageImagesByAttachmentId}
/>
{/* Source identity */}
<span className="flex items-center gap-1 shrink-0">
<FaviconImage faviconUrl={sourceFavicon} domain={item.citation.url} alt={sourceName} />
<span className="text-xs text-dc-muted-foreground truncate max-w-[120px]">{sourceName}</span>
</span>
{/* Anchor text */}
<span className="text-dc-foreground truncate min-w-0 flex-1">{anchorText}</span>
</div>
);
}
```

### 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.
16 changes: 8 additions & 8 deletions src/__tests__/CitationDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,7 @@ describe("CitationDrawer page badges", () => {
const { container } = render(<CitationDrawer isOpen={true} onClose={() => {}} 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");
});
Expand Down Expand Up @@ -1362,7 +1362,7 @@ describe("CitationDrawer page badges", () => {
const { container } = render(<CitationDrawer isOpen={true} onClose={() => {}} 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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1527,8 +1527,8 @@ describe("CitationDrawer page badges", () => {
const { container } = render(<CitationDrawer isOpen={true} onClose={() => {}} 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();

Expand Down
31 changes: 21 additions & 10 deletions src/__tests__/EvidenceTray.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,23 @@
}

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(
<EvidenceTray verification={baseVerification} status={baseStatus} onExpand={() => {}} />,
);

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

Check warning on line 65 in src/__tests__/EvidenceTray.test.tsx

View workflow job for this annotation

GitHub Actions / lint-and-validate

lint/style/noNonNullAssertion

Forbidden non-null assertion.
expect(viewPageButton!.className).toContain("hover:text-dc-foreground");

Check warning on line 66 in src/__tests__/EvidenceTray.test.tsx

View workflow job for this annotation

GitHub Actions / lint-and-validate

lint/style/noNonNullAssertion

Forbidden non-null assertion.
expect(viewPageButton!.className).toContain("focus-visible:ring-2");

Check warning on line 67 in src/__tests__/EvidenceTray.test.tsx

View workflow job for this annotation

GitHub Actions / lint-and-validate

lint/style/noNonNullAssertion

Forbidden non-null assertion.
});

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(
<EvidenceTray
verification={baseVerification}
status={baseStatus}
Expand All @@ -74,8 +79,8 @@
/>,
);

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", () => {
Expand Down Expand Up @@ -135,13 +140,19 @@
],
};

const { getByRole, getByText, queryByText } = render(
const { container, getByText, queryByText } = render(
<EvidenceTray verification={missVerification} status={missStatus} onExpand={onExpand} />,
);

// 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!);

Check warning on line 155 in src/__tests__/EvidenceTray.test.tsx

View workflow job for this annotation

GitHub Actions / lint-and-validate

lint/style/noNonNullAssertion

Forbidden non-null assertion.
});
const attemptRowText = getByText("alpha");

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/StatusHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe("StatusHeader", () => {
it("uses green icon color for verified status", () => {
const { container } = render(<StatusHeader status="found" foundPage={5} />);

const greenIcon = container.querySelector(".text-green-600");
const greenIcon = container.querySelector(".text-green-700");
expect(greenIcon).toBeInTheDocument();
});

Expand Down
19 changes: 5 additions & 14 deletions src/__tests__/VerificationLogComponents.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -294,14 +295,9 @@ describe("VerificationLogTimeline attempts table", () => {
},
];

const narrative = buildSearchNarrative(searchAttempts, "found_on_other_page", 5, 12);
const { getByText } = render(
<VerificationLogTimeline
searchAttempts={searchAttempts}
status="found_on_other_page"
fullPhrase="Revenue increased by 15% in Q4 2024."
expectedPage={5}
expectedLine={12}
/>,
<VerificationLogTimeline narrative={narrative} fullPhrase="Revenue increased by 15% in Q4 2024." />,
);

expect(getByText("Revenue increased by 15% in Q4 2024.")).toBeInTheDocument();
Expand All @@ -324,14 +320,9 @@ describe("VerificationLogTimeline attempts table", () => {
},
];

const narrative = buildSearchNarrative(searchAttempts, "found_on_other_line", 5, 12);
const { getByText } = render(
<VerificationLogTimeline
searchAttempts={searchAttempts}
status="found_on_other_line"
fullPhrase="Revenue increased by 15% in Q4 2024."
expectedPage={5}
expectedLine={12}
/>,
<VerificationLogTimeline narrative={narrative} fullPhrase="Revenue increased by 15% in Q4 2024." />,
);

const unexpectedLocation = getByText(/^p[.\s\u202f]+7\s*\u00b7\s*l[.\s\u202f]+22$/);
Expand Down
Loading
Loading