| ` (empty, for label column) + one ` | ` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and prepend a trophy icon: ` | ` and ` | ` in the first (label) column MUST have: `sticky left-0 z-10 bg-white`. Without `bg-white`, scrolled content bleeds through. Use `z-10` (not higher — avoid conflicts with panels/modals).
**Attribute row order** (per locked decision): Image, Name, Rank, Weight (with delta), Price (with delta), Status, Product Link, Notes, Pros, Cons.
**Row rendering — use a declarative array pattern:**
Define `ATTRIBUTE_ROWS` as an array of `{ key, label, render(candidate) }`. This keeps the JSX clean and makes row reordering trivial. Build this array inside the component function body (after useMemo hooks) so it can close over `weightDeltas`, `priceDeltas`, `bestWeightId`, `bestPriceId`, `unit`, `currency`.
**Cell renderers:**
- **Image**: 48x48 rounded-lg container. If `imageFilename`, render ` | `. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show `—`.
- **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for the best cell.
- **Status**: Render as static text `{STATUS_LABELS[status]}`. Define STATUS_LABELS map: `{ researching: "Researching", ordered: "Ordered", arrived: "Arrived" }`. No click-to-cycle in compare view — comparison is for reading, not mutation.
- **Product Link**: If `productUrl` exists, render a clickable link that calls `openExternalLink(productUrl)` from uiStore: ``. If null, render `—`. Links remain clickable even in resolved threads (read-only means no mutations, but navigation is fine).
- **Notes**: If `notes` exists, render ` {notes} ` (whitespace-pre-line preserves newlines). If null, render em dash placeholder. - **Pros**: If `pros` exists, split on `"\n"`, filter empty strings, render as `
|
|---|