Files
GearBox/.planning/phases/12-comparison-view/12-RESEARCH.md

29 KiB

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

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:

// 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:

// 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:

// 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:

{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:

// 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:

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

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

// 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

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

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)

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

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

Tertiary (LOW confidence — WebSearch, not verified against official spec)


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)