# Phase 12: Comparison View - Research

**Researched:** 2026-03-17
**Domain:** React tabular UI, CSS sticky columns, horizontal scroll, delta computation
**Confidence:** HIGH

## Summary

Phase 12 is a pure frontend phase. No backend changes, no schema changes, no new npm packages. All required data is already returned by `useThread(threadId)` — candidates carry `weightGrams`, `priceCents`, `status`, `productUrl`, `notes`, `pros`, `cons`, `imageFilename`, `categoryIcon`, and rank is derived from sort_order position in the array. The work is entirely in building a `ComparisonTable` component, wiring a third toggle button into the existing view-mode bar, and extending the `candidateViewMode` Zustand union type.

The core CSS challenge is the sticky-first-column + horizontal-scroll table pattern. Modern CSS handles this well as long as `overflow-x: auto` is placed on a wrapper `<div>`, not the `<table>` element itself, and the sticky `<td>` cells in the label column have an explicit background color (otherwise scrolling content bleeds through). Z-index layering is simple for this use case because there is only one sticky axis (the left label column); no sticky top header is needed since the table is not vertically scrollable.

Delta computation is straightforward arithmetic: find the minimum `weightGrams` across candidates that have a value, subtract each candidate's value from that minimum to produce a delta, and render a `+Xg` or `—` string. The "best" cell gets `bg-blue-50` for weight (matching existing blue weight pill color) or `bg-green-50` for price (matching existing green price pill color). Missing data must never display as "0" — a dash placeholder is required by COMP-04, and `formatWeight(null)` already returns `"--"`.

**Primary recommendation:** Build `ComparisonTable.tsx` as a self-contained component that accepts `candidates[]` and `resolvedCandidateId | null`, computes deltas internally with `useMemo`, renders a `<div className="overflow-x-auto">` wrapper around a plain `<table>`, and uses `sticky left-0 bg-white z-10` on the label `<td>` cells.

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Compare mode entry point: Add a third icon to the existing list/grid toggle bar, making it list | grid | compare (three-way toggle)
- Use `candidateViewMode: 'list' | 'grid' | 'compare'` in uiStore — extends the existing Zustand state
- Compare icon only appears when 2+ candidates exist in the thread (hidden otherwise)
- Table orientation: Candidates as columns, attribute labels as rows (classic product-comparison pattern — Amazon/Wirecutter style)
- Sticky left column for attribute labels; table scrolls horizontally on narrow viewports
- Attribute row order: Image → Name → Rank → Weight (with delta) → Price (with delta) → Status → Product Link → Notes → Pros → Cons
- Delta highlighting style: Lightest candidate's weight cell gets a subtle colored background tint (e.g., bg-green-50); cheapest similarly
- Non-best cells show delta text in neutral gray — no colored badges for deltas, only the "best" cell gets color

### Claude's Discretion
- "Add Candidate" button visibility when in compare view
- Image thumbnail sizing in comparison cells (square crop vs wider aspect)
- Multi-line text rendering strategy (clamped with expand vs full text)
- Missing data indicator style (dash with label, empty cell, etc.)
- Delta format: absolute value + delta underneath, or delta only for non-best cells
- Winner column marking approach (column tint, trophy icon, or both)
- Resolved thread interactivity (links clickable vs all read-only)
- Resolution banner behavior in compare view
- View mode persistence (already in Zustand — whether compare resets on navigation or persists)
- Compare toggle icon choice (e.g., Lucide `columns-3`, `table-2`, or similar)
- Table cell padding, border styling, and overall table chrome
- Column minimum/maximum widths
- Keyboard accessibility for horizontal scrolling

### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| COMP-01 | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | ComparisonTable component; all fields available from useThread hook; no backend changes needed |
| COMP-02 | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | Delta computation via array reduce; best-cell highlight via bg-blue-50 (weight) / bg-green-50 (price); gray delta text for non-best |
| COMP-03 | Comparison table scrolls horizontally with a sticky label column on narrow viewports | overflow-x-auto wrapper div + sticky left-0 bg-white z-10 on label td cells |
| COMP-04 | Comparison view displays read-only summary for resolved threads | resolvedCandidateId from useThread; disable mutation actions; winner column visual tint; resolved check pattern established in Phase 11 |
</phase_requirements>

---

## Standard Stack

### Core (all already installed — no new packages needed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| React 19 | ^19.2.4 | Component rendering | Project stack |
| Tailwind CSS | v4 | Utility styling | Project stack |
| Zustand | ^5.0.11 | candidateViewMode state | Already used for list/grid toggle |
| lucide-react | ^0.577.0 | Toggle icon (`columns-3` confirmed present) | All icons use LucideIcon helper |
| framer-motion | ^12.37.0 | Optional AnimatePresence for view transition | Already installed |

### Supporting Utilities (already in project)
| Utility | Location | Purpose |
|---------|----------|---------|
| `formatWeight(grams, unit)` | `src/client/lib/formatters.ts` | Weight cell values and delta strings; returns `"--"` for null |
| `formatPrice(cents, currency)` | `src/client/lib/formatters.ts` | Price cell values and delta strings; returns `"--"` for null |
| `useWeightUnit()` | `src/client/hooks/useWeightUnit.ts` | Current unit setting |
| `useCurrency()` | `src/client/hooks/useCurrency.ts` | Current currency setting |
| `useThread(threadId)` | `src/client/hooks/useThreads.ts` | All candidate data |
| `RankBadge` | `src/client/components/CandidateListItem.tsx` | Rank medal icons (exported) |
| `LucideIcon` | `src/client/lib/iconData.tsx` | Icon rendering with fallback |

---

## Architecture Patterns

### Recommended File Structure
```
src/client/
├── components/
│   └── ComparisonTable.tsx     # New: tabular comparison component
├── stores/
│   └── uiStore.ts              # Modify: extend candidateViewMode union type
└── routes/threads/
    └── $threadId.tsx           # Modify: add compare branch + third toggle button
```

### Pattern 1: Sticky Left Column with Horizontal Scroll

**What:** Wrap `<table>` in `<div className="overflow-x-auto">`. Apply `sticky left-0 bg-white z-10` to every `<td>` and `<th>` in the first (label) column.

**When to use:** Any time a table needs a frozen left column with horizontal scrolling.

**Critical pitfall:** The sticky `td` cells MUST have a solid background color. Without `bg-white`, scrolling content bleeds through the "sticky" cell because the cell is transparent.

**Example:**
```tsx
// Outer wrapper enables horizontal scroll
<div className="overflow-x-auto rounded-xl border border-gray-100">
  <table
    className="border-collapse text-sm"
    style={{ minWidth: `${Math.max(400, candidates.length * 180)}px` }}
  >
    <thead>
      <tr className="border-b border-gray-100">
        {/* Sticky corner cell — bg-white mandatory */}
        <th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wide w-28" />
        {candidates.map((c) => (
          <th key={c.id} className="px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]">
            {c.name}
          </th>
        ))}
      </tr>
    </thead>
    <tbody>
      {ATTRIBUTE_ROWS.map((row) => (
        <tr key={row.key} className="border-b border-gray-50">
          {/* Sticky label cell — bg-white mandatory */}
          <td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500">
            {row.label}
          </td>
          {candidates.map((c) => (
            <td key={c.id} className="px-4 py-3">
              {row.render(c)}
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  </table>
</div>
```

### Pattern 2: Delta Computation (null-safe, useMemo)

**What:** Derive the "best" candidate and compute deltas before rendering. Use `useMemo` keyed on `candidates` to avoid recomputing on every render.

**Example:**
```tsx
// Source: derived from project formatters.ts patterns
const { weightDeltas, bestWeightId } = useMemo(() => {
  const withWeight = candidates.filter((c) => c.weightGrams != null);
  if (withWeight.length === 0) return { weightDeltas: new Map<number, string | null>(), bestWeightId: null };

  const minGrams = Math.min(...withWeight.map((c) => c.weightGrams as number));
  const bestWeightId = withWeight.find((c) => c.weightGrams === minGrams)!.id;

  const weightDeltas = new Map(
    candidates.map((c) => {
      if (c.weightGrams == null) return [c.id, null]; // null = missing data
      const delta = c.weightGrams - minGrams;
      return [c.id, delta === 0 ? null : `+${formatWeight(delta, unit)}`];
      // delta === 0 means this IS the best — no delta string needed
    })
  );
  return { weightDeltas, bestWeightId };
}, [candidates, unit]);
```

### Pattern 3: Extending Zustand Union Type

**What:** Widen the existing `candidateViewMode` type from `'list' | 'grid'` to `'list' | 'grid' | 'compare'`. The implementation setter line is unchanged.

**Example:**
```typescript
// In uiStore.ts — only two type declaration lines change (lines 53-54):
candidateViewMode: "list" | "grid" | "compare";
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;

// Implementation lines 112-113 — unchanged:
candidateViewMode: "list",
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
```

### Pattern 4: Three-Way Toggle Button

**What:** Add a third button to the existing `bg-gray-100 rounded-lg p-0.5` toggle bar in `$threadId.tsx`. Show compare button only when `thread.candidates.length >= 2`.

**Example:**
```tsx
{thread.candidates.length >= 2 && (
  <button
    type="button"
    onClick={() => setCandidateViewMode("compare")}
    className={`p-1.5 rounded-md transition-colors ${
      candidateViewMode === "compare"
        ? "bg-gray-200 text-gray-900"
        : "text-gray-400 hover:text-gray-600"
    }`}
    title="Compare view"
  >
    <LucideIcon name="columns-3" size={16} />
  </button>
)}
```

**Confirmed:** `columns-3` maps to `Columns3` in lucide-react ^0.577.0 and is present in the installed package (verified via `node -e "const {icons}=require('lucide-react'); console.log('Columns3' in icons)"`). Use `LucideIcon name="columns-3"` — the LucideIcon helper handles the `toPascalCase` conversion.

### Pattern 5: Row Definition as Data

**What:** Define the attribute rows as a declarative array, not hard-coded JSX branches. Each entry has a `key`, `label`, and a `render(candidate)` function. This makes row reordering trivial and matches the locked attribute order.

**Example:**
```tsx
// Attribute row order per CONTEXT.md: Image → Name → Rank → Weight → Price → Status → Link → Notes → Pros → Cons
const ATTRIBUTE_ROWS = [
  { key: "image",  label: "Image",  render: (c: C) => <ImageCell candidate={c} /> },
  { key: "name",   label: "Name",   render: (c: C) => <span className="text-sm font-medium text-gray-900">{c.name}</span> },
  { key: "rank",   label: "Rank",   render: (c: C) => <RankBadge rank={rankOf(c)} /> },
  { key: "weight", label: "Weight", render: (c: C) => <WeightCell candidate={c} delta={weightDeltas.get(c.id)} isBest={c.id === bestWeightId} unit={unit} /> },
  { key: "price",  label: "Price",  render: (c: C) => <PriceCell candidate={c} delta={priceDeltas.get(c.id)} isBest={c.id === bestPriceId} currency={currency} /> },
  { key: "status", label: "Status", render: (c: C) => <span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span> },
  { key: "link",   label: "Link",   render: (c: C) => c.productUrl ? <a href="#" onClick={() => openExternalLink(c.productUrl!)} className="text-xs text-blue-500 hover:underline">View</a> : <span className="text-gray-300">—</span> },
  { key: "notes",  label: "Notes",  render: (c: C) => <TextCell text={c.notes} /> },
  { key: "pros",   label: "Pros",   render: (c: C) => <BulletCell text={c.pros} /> },
  { key: "cons",   label: "Cons",   render: (c: C) => <BulletCell text={c.cons} /> },
];
```

### Pattern 6: Pros/Cons Rendering (confirmed newline-separated)

**What:** `CandidateForm.tsx` uses a `<textarea>` with placeholder "One pro per line..." — users enter newline-separated text. The form submits `form.pros.trim() || undefined`, so empty = `undefined` → stored as `null` in DB. Non-empty content is raw text with `\n` separators.

**How to render in compare table:**
```tsx
function BulletCell({ text }: { text: string | null }) {
  if (!text) return <span className="text-gray-300">—</span>;
  const items = text.split("\n").filter(Boolean);
  if (items.length === 0) return <span className="text-gray-300">—</span>;
  return (
    <ul className="list-disc list-inside space-y-0.5">
      {items.map((item, i) => (
        <li key={i} className="text-xs text-gray-700">{item}</li>
      ))}
    </ul>
  );
}
```

### Anti-Patterns to Avoid

- **Setting `overflow-x-auto` on `<table>` directly:** Has no effect in CSS. Must be on a wrapper `<div>`.
- **Transparent sticky cells:** Sticky `<td>` cells without `bg-white` let scrolled content bleed through visually.
- **Computing deltas inside render:** Use `useMemo` — compute once, not per render cycle.
- **Using `overflow: hidden` on any ancestor of the sticky column:** Breaks the sticky positioning context.
- **Missing data shown as "0":** `formatWeight(null)` already returns `"--"`. Guard delta computation with null checks before arithmetic.
- **Rendering pros/cons as raw string:** Split on `\n` and render as `<ul>` — the form stores `\n`-separated text.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Weight/price formatting | Custom format functions | `formatWeight()` / `formatPrice()` in `formatters.ts` | Handles all units, currencies, null — returns `"--"` for null |
| Rank medal icons | Custom SVG or color dots | `RankBadge` from `CandidateListItem.tsx` | Already exported, handles ranks 1-3 with correct colors |
| Zustand state | Local useState for view mode | Existing `candidateViewMode` in `uiStore` | Persists across navigation, consistent with list/grid |
| Icon rendering | Direct lucide component imports | `LucideIcon` helper from `iconData.tsx` | Handles fallback, consistent API across project |
| Unit/currency awareness | Hardcode "g" or "$" | `useWeightUnit()` / `useCurrency()` | Reads from user settings |

**Key insight:** This phase is almost entirely composition of already-built primitives. The delta computation logic and sticky column CSS are the only genuinely new work.

---

## Common Pitfalls

### Pitfall 1: Sticky Cell Background Bleed-Through
**What goes wrong:** The label column appears sticky but scrolling content renders on top of it, making text illegible.
**Why it happens:** `position: sticky` keeps the element in its visual position but does not create an opaque layer. Without a background color the cell is transparent.
**How to avoid:** Add `bg-white` to every sticky `<td>` and `<th>` in the label column. If alternating row backgrounds are used, the sticky cells must also match those background colors.
**Warning signs:** Label text becomes unreadable when scrolling horizontally.

### Pitfall 2: overflow-x-auto on Wrong Element
**What goes wrong:** The table never scrolls horizontally regardless of viewport width.
**Why it happens:** CSS `overflow` properties only apply to block/flex/grid containers. `<table>` is a table container — `overflow-x: auto` on `<table>` has no effect per CSS spec.
**How to avoid:** Wrap `<table>` in `<div className="overflow-x-auto">`. Set `minWidth` on the `<table>` itself (not the wrapper) to force scrollability.
**Warning signs:** Table content wraps aggressively instead of scrolling; columns collapse on narrow screens.

### Pitfall 3: Delta Shows for Best Candidate
**What goes wrong:** The lightest candidate shows "+0g" instead of just the value cleanly.
**Why it happens:** Naive `delta = candidate - min` yields 0 for the best candidate.
**How to avoid:** When `delta === 0`, return `null` for the delta string. The best-cell highlight color already communicates "this is best." Only non-best cells show a delta string.
**Warning signs:** Best cell shows "+0g" or "+$0.00" alongside the colored highlight.

### Pitfall 4: Missing Data Rendered as Zero (COMP-04 violation)
**What goes wrong:** A candidate with `weightGrams: null` shows "0g" in the weight row, misleading the user.
**Why it happens:** Passing `null` through subtraction arithmetic silently produces 0 in JavaScript.
**How to avoid:** Guard before computing: `if (c.weightGrams == null) return [c.id, null]`. In the cell renderer, when value is null, render `—` (em dash).
**Warning signs:** COMP-04 violated; user appears to see "0g" for an item with no weight entered.

### Pitfall 5: z-index Conflicts with Panels/Dropdowns
**What goes wrong:** Sticky label column renders above the SlideOutPanel or modal overlays.
**Why it happens:** Using `z-index: 50` or higher on sticky cells competes with panel z-index values.
**How to avoid:** Use `z-index: 10` (Tailwind `z-10`) for sticky cells. They only need to be above the regular table body cells (z-index: auto). The compare view has no interactive StatusBadge dropdowns (read-only in resolved mode; in active mode the compare view is navigational, not mutation-focused).
**Warning signs:** Sticky column clips or obscures slide-out panels.

### Pitfall 6: Pros/Cons Rendered as Raw String
**What goes wrong:** A candidate's pros appear as a single run-on text block with no formatting.
**Why it happens:** `CandidateForm` stores pros/cons as newline-separated plain text. Plain JSX `{candidate.pros}` ignores newlines in HTML.
**How to avoid:** Split on `"\n"`, filter empty strings, render as `<ul>/<li>`. Confirmed from `CandidateForm.tsx` textarea with "One pro per line..." placeholder.
**Warning signs:** All pro/con items concatenated without separation.

---

## Code Examples

Verified patterns from project source:

### Extending uiStore candidateViewMode
```typescript
// src/client/stores/uiStore.ts — lines 53-54 today read:
//   candidateViewMode: "list" | "grid";
//   setCandidateViewMode: (mode: "list" | "grid") => void;

// After change (only these two lines change in the interface):
candidateViewMode: "list" | "grid" | "compare";
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;

// Implementation at lines 112-113 — no change needed:
candidateViewMode: "list",
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
```

### threadId.tsx integration point (current line 192)
```tsx
// Current conditional rendering at line 192:
// ) : candidateViewMode === "list" ? (
//   <Reorder.Group ... />  or  <div ... />
// ) : (
//   <div className="grid ..."> (grid view)
// )

// After change — add compare branch before list check:
} : candidateViewMode === "compare" ? (
  <ComparisonTable
    candidates={displayItems}
    resolvedCandidateId={thread.resolvedCandidateId}
  />
) : candidateViewMode === "list" ? (
  // list rendering (unchanged)
) : (
  // grid rendering (unchanged)
)
```

### Minimum viable ComparisonTable props interface
```typescript
// Reuse the CandidateWithCategory type from hooks/useThreads.ts
interface ComparisonTableProps {
  candidates: CandidateWithCategory[];   // already typed in useThreads.ts
  resolvedCandidateId: number | null;    // for winner column highlight
}
```

### Full delta computation with useMemo (null-safe)
```typescript
const { priceDeltas, bestPriceId } = useMemo(() => {
  const withPrice = candidates.filter((c) => c.priceCents != null);
  if (withPrice.length === 0) {
    return { priceDeltas: new Map<number, string | null>(), bestPriceId: null };
  }
  const minCents = Math.min(...withPrice.map((c) => c.priceCents as number));
  const bestPriceId = withPrice.find((c) => c.priceCents === minCents)!.id;
  const priceDeltas = new Map(
    candidates.map((c) => {
      if (c.priceCents == null) return [c.id, null];          // missing data
      const delta = c.priceCents - minCents;
      return [c.id, delta === 0 ? null : `+${formatPrice(delta, currency)}`];
    })
  );
  return { priceDeltas, bestPriceId };
}, [candidates, currency]);
```

### Best-cell highlight pattern (weight example)
```tsx
// Weight cell — bg-blue-50 for "lightest" (matches existing blue weight pills in CandidateListItem)
function WeightCell({ candidate, delta, isBest, unit }: WeightCellProps) {
  return (
    <td className={`px-4 py-3 text-sm ${isBest ? "bg-blue-50" : ""}`}>
      {candidate.weightGrams != null ? (
        <>
          <span className="font-medium text-gray-900">
            {formatWeight(candidate.weightGrams, unit)}
          </span>
          {delta && (
            <span className="block text-xs text-gray-400 mt-0.5">{delta}</span>
          )}
        </>
      ) : (
        <span className="text-gray-300">—</span>
      )}
    </td>
  );
}
```

Note: CONTEXT.md uses "bg-green-50" as the example for lightest weight. Recommend aligning with existing project badge colors: lightest weight → `bg-blue-50` (consistent with blue weight pills), cheapest price → `bg-green-50` (consistent with green price pills). This is within Claude's discretion.

### Winner column pattern (resolved threads)
```tsx
// Column header for the winning candidate gets amber tint (matches resolution banner)
<th
  key={c.id}
  className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
    c.id === resolvedCandidateId
      ? "bg-amber-50 text-amber-800"
      : "text-gray-700"
  }`}
>
  <div className="flex items-center gap-1.5">
    {c.id === resolvedCandidateId && (
      <LucideIcon name="trophy" size={12} className="text-amber-600" />
    )}
    {c.name}
  </div>
</th>
```

---

## State of the Art

| Old Approach | Current Approach | Notes |
|--------------|------------------|-------|
| Hand-rolled format functions | Reuse `formatWeight` / `formatPrice` with delta arithmetic | Project formatters already handle all units, currencies, and null |
| `overflow-x: auto` on `<table>` | `overflow-x-auto` on wrapper `<div>` | CSS spec: overflow only applies to block containers |
| JS-based sticky columns | CSS `position: sticky` with `left: 0` | 92%+ browser support, zero JS overhead |
| Inline column rendering | Declarative row-definition array | Matches the locked attribute order, easy to maintain |

**Deprecated/outdated:**
- Direct lucide icon component imports (e.g., `import { LayoutList } from "lucide-react"`): project uses `LucideIcon` helper uniformly — follow the same pattern.

---

## Open Questions

All questions resolved during research:

1. **Pros/cons storage format — RESOLVED**
   - `CandidateForm.tsx` uses a `<textarea>` with "One pro per line..." placeholder
   - Submit handler: `pros: form.pros.trim() || undefined` — empty string → not sent → stored as `null`
   - Non-empty content: raw multiline text stored as-is, newline-separated
   - **Action for planner:** Use `BulletCell` pattern (split on `\n`, render `<ul>/<li>`)

2. **`columns-3` icon availability — RESOLVED**
   - Verified: `Columns3` is present in lucide-react ^0.577.0 installed package
   - Use `<LucideIcon name="columns-3" size={16} />` — the LucideIcon helper converts to PascalCase
   - `table-2` is also present as a backup if needed

3. **"Add Candidate" button in compare mode — RECOMMENDATION**
   - Currently guarded by `{isActive && ...}` in `$threadId.tsx`
   - Recommendation: hide "Add Candidate" when `candidateViewMode === "compare"` (keep toolbar uncluttered; users switch to list/grid to add)
   - Implementation: add `&& candidateViewMode !== "compare"` to the existing `isActive` guard

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Bun test (built-in) |
| Config file | None — uses `bun test` directly |
| Quick run command | `bun test tests/lib/` |
| Full suite command | `bun test` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| COMP-01 | Candidates display all required fields in table | manual-only (UI/browser) | — | N/A |
| COMP-02 | Delta computation: null-safe, best candidate identified, zero-delta suppressed | unit (if extracted to util) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ Wave 0 gap (optional) |
| COMP-03 | Table scrolls horizontally / sticky label column stays fixed | manual-only (CSS/browser) | — | N/A |
| COMP-04 | Resolved thread shows read-only view with winner marked; no zero for missing data | manual-only (UI state) | — | N/A |

**Note on testing scope:** COMP-01, COMP-03, COMP-04 are UI/browser behaviors. COMP-02 delta logic is pure arithmetic — testable if extracted to a standalone utility function. This is a pure frontend phase; the existing `bun test` suite covers backend services only and will not be broken by this phase.

### Sampling Rate
- **Per task commit:** `bun test` (full suite, fast — no UI tests in suite)
- **Per wave merge:** `bun test`
- **Phase gate:** Full suite green + manual browser verification of scroll/sticky behavior on a narrow viewport

### Wave 0 Gaps
- [ ] `tests/lib/comparison-deltas.test.ts` — covers COMP-02 delta logic if extracted to a pure utility (optional; skip if deltas stay inlined in the React component)

*(If delta computation stays in the React component via `useMemo`, no new test files are needed — COMP-02 is verified manually in the browser.)*

---

## Sources

### Primary (HIGH confidence)
- Project codebase direct inspection:
  - `src/client/stores/uiStore.ts` — confirmed `candidateViewMode` type and setter
  - `src/client/routes/threads/$threadId.tsx` — confirmed integration points, toggle bar pattern, lines to modify
  - `src/client/components/CandidateListItem.tsx` — confirmed `RankBadge` export, `CandidateWithCategory` interface
  - `src/client/components/CandidateCard.tsx` — confirmed field usage patterns
  - `src/client/components/CandidateForm.tsx` — confirmed pros/cons are newline-separated textarea input; empty = null
  - `src/client/lib/formatters.ts` — confirmed null handling, `"--"` return for null
  - `src/client/hooks/useThreads.ts` — confirmed `CandidateWithCategory` shape with all fields needed
  - `package.json` — confirmed no new dependencies needed
- Runtime verification: `Columns3` in lucide-react ^0.577.0 confirmed present via node script

### Secondary (MEDIUM confidence)
- [Tailwind CSS overflow docs](https://tailwindcss.com/docs/overflow) — `overflow-x-auto` on wrapper div pattern
- [Tailwind CSS position docs](https://tailwindcss.com/docs/position) — sticky utility, z-index behavior
- [Lexington Themes — scrollable sticky header table](https://lexingtonthemes.com/blog/how-to-build-a-scrollable-table-with-sticky-header-using-tailwind-css) — confirmed sticky thead pattern with Tailwind

### Tertiary (LOW confidence — WebSearch, not verified against official spec)
- [Multi-Directional Sticky CSS (Medium, Jan 2026)](https://medium.com/@ashutoshgautam10b11/multi-directional-sticky-css-and-horizontal-scroll-in-tables-41fc25c3ce8b) — z-index layering reference; this phase only needs one sticky axis (left column, z-10 suffices)
- [DEV Community — sticky frozen column](https://dev.to/nicolaserny/table-with-a-fixed-first-column-2c5b) — background color requirement for sticky cells confirmed
- [freeCodeCamp Forum — overflow + sticky](https://forum.freecodecamp.org/t/fixing-sticky-table-header-with-horizontal-scroll-in-a-scrollable-container/735559) — overflow-x-auto must be on wrapper div, not table element

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all dependencies confirmed present in package.json; no new packages
- Architecture: HIGH — directly derived from reading all relevant project source files
- Pitfalls: HIGH — sticky bg issue confirmed by multiple sources; overflow-on-table confirmed by CSS spec; pros/cons newline format confirmed from CandidateForm source
- Delta computation: HIGH — pure arithmetic, formatters already handle null, confirmed return values

**Research date:** 2026-03-17
**Valid until:** 2026-04-17 (stable CSS, stable React, stable project codebase)
