fix(drug-reference): responsive compare columns and dark-mode readability (#1163)

This commit is contained in:
chriscrosstalk 2026-07-28 18:18:47 -07:00 committed by jakeaturner
parent de6f453fa7
commit fb99b3993b
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
7 changed files with 55 additions and 50 deletions

View File

@ -47,11 +47,11 @@ export default function DrugResultRow({ result, brandFirst = false }: Props) {
return (
<Link
href={`/drug-reference/${result.id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors group"
className="flex items-center justify-between px-4 py-3 hover:bg-surface-secondary transition-colors group"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm text-gray-900 group-hover:text-desert-green truncate">
<span className="font-semibold text-sm text-text-primary group-hover:text-desert-green truncate">
{headline}
</span>
@ -67,14 +67,14 @@ export default function DrugResultRow({ result, brandFirst = false }: Props) {
)}
{result.labelCount > 1 && (
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 flex-shrink-0">
<span className="px-1.5 py-0.5 rounded text-xs bg-surface-secondary text-text-secondary flex-shrink-0">
{result.labelCount} labels
</span>
)}
</div>
{subParts.length > 0 && (
<div className="flex flex-wrap gap-x-3 mt-0.5 text-xs text-gray-500">
<div className="flex flex-wrap gap-x-3 mt-0.5 text-xs text-text-secondary">
{subParts.map((p, i) => (
<span key={i} className="truncate">
{p}
@ -84,7 +84,7 @@ export default function DrugResultRow({ result, brandFirst = false }: Props) {
)}
</div>
<span className="ml-3 text-gray-400 text-xs flex-shrink-0"></span>
<span className="ml-3 text-text-muted text-xs flex-shrink-0"></span>
</Link>
)
}

View File

@ -62,14 +62,14 @@ function PhaseBlock({
? 'text-green-700'
: running
? accentRunning
: 'text-gray-500'
: 'text-text-secondary'
const barColor = failed
? 'bg-red-500'
: completed
? 'bg-green-500'
: running
? accentRunning.replace('text-', 'bg-')
: 'bg-gray-300'
: 'bg-surface-elevated'
return (
<div className="space-y-1">
@ -83,14 +83,14 @@ function PhaseBlock({
)}
</div>
<p className="text-xs text-gray-500">{explainer}</p>
<p className="text-xs text-text-secondary">{explainer}</p>
{counterLeft && (
<div className="text-xs text-gray-600 tabular-nums">{counterLeft}</div>
<div className="text-xs text-text-secondary tabular-nums">{counterLeft}</div>
)}
{(running || completed || failed) && (
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div className="w-full bg-surface-elevated rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${failed ? 100 : pct}%` }}
@ -206,7 +206,7 @@ export default function IngestStatus({ status, onRefresh, pollIntervalMs = 3000
const downloadTiming =
status.phase === 'downloading' && elapsedMs !== null ? (
<div className="flex flex-wrap items-center gap-x-3 text-xs text-gray-500 tabular-nums">
<div className="flex flex-wrap items-center gap-x-3 text-xs text-text-secondary tabular-nums">
<span>Elapsed {fmtDuration(elapsedMs)}</span>
{dlBytes && <span>{dlBytes} this part</span>}
</div>
@ -214,11 +214,11 @@ export default function IngestStatus({ status, onRefresh, pollIntervalMs = 3000
const ingestTiming =
status.phase === 'ingesting' && elapsedMs !== null ? (
<div className="flex flex-wrap items-center gap-x-3 text-xs text-gray-500 tabular-nums">
<div className="flex flex-wrap items-center gap-x-3 text-xs text-text-secondary tabular-nums">
<span>Elapsed {fmtDuration(elapsedMs)}</span>
{etaMs !== null && (
<span>
~{fmtDuration(etaMs)} left <span className="text-gray-400">(estimate)</span>
~{fmtDuration(etaMs)} left <span className="text-text-muted">(estimate)</span>
</span>
)}
</div>
@ -247,11 +247,11 @@ export default function IngestStatus({ status, onRefresh, pollIntervalMs = 3000
counterLeft={
ingest.state === 'running' || ingest.state === 'completed' ? (
<span>
<span className="text-sm font-semibold text-gray-800">
<span className="text-sm font-semibold text-text-primary">
{ingest.records.toLocaleString()}
</span>
{expected > 0 && (
<span className="text-gray-400"> of ~{expected.toLocaleString()} labels</span>
<span className="text-text-muted"> of ~{expected.toLocaleString()} labels</span>
)}
</span>
) : undefined
@ -262,7 +262,7 @@ export default function IngestStatus({ status, onRefresh, pollIntervalMs = 3000
{/* Reassurance while busy */}
{busy && (
<p className="text-xs text-gray-400">
<p className="text-xs text-text-muted">
Runs in the background you can leave this page and it keeps going. Search turns on
automatically when ingest finishes.
</p>
@ -270,7 +270,7 @@ export default function IngestStatus({ status, onRefresh, pollIntervalMs = 3000
{/* Ready footer */}
{status.phase === 'ready' && status.lastUpdated && (
<p className="text-xs text-gray-500">FDA data version {status.lastUpdated}.</p>
<p className="text-xs text-text-secondary">FDA data version {status.lastUpdated}.</p>
)}
</div>
)

View File

@ -40,7 +40,7 @@ export default function IngredientGroup({
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-gray-50 transition-colors"
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-surface-secondary transition-colors"
>
<IconChevronDown
size={16}

View File

@ -16,7 +16,7 @@ export default function LabelBlocks({
}) {
const blocks = parseLabelSection(text)
if (blocks.length === 0) return null
const bodyColor = tone === 'danger' ? 'text-red-800' : 'text-gray-800'
const bodyColor = tone === 'danger' ? 'text-red-800' : 'text-text-primary'
return (
<div className="space-y-3">
{blocks.map((block, i) => (
@ -39,7 +39,7 @@ function LabelBlockView({ block, bodyColor }: { block: LabelBlock; bodyColor: st
if (isLabelSectionHeader(block.text)) {
return (
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
<p className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
{block.text!.replace(/^\s*\d{1,2}\s+/, '')}
</p>
)
@ -48,7 +48,7 @@ function LabelBlockView({ block, bodyColor }: { block: LabelBlock; bodyColor: st
return (
<p className={`text-sm ${bodyColor} leading-relaxed`}>
{block.label && (
<span className="inline-block mr-1.5 px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 text-xs font-semibold align-baseline">
<span className="inline-block mr-1.5 px-1.5 py-0.5 rounded bg-surface-secondary text-text-secondary text-xs font-semibold align-baseline">
{block.label}
</span>
)}

View File

@ -547,7 +547,7 @@ export default function DrugReferenceIndex({
onChange={handleQueryChange}
autoFocus
placeholder="Search a medicine by name — ibuprofen, Benadryl, loratadine…"
className="w-full rounded-lg border border-desert-stone-lighter bg-white py-2.5 pl-10 pr-4 text-sm text-desert-green-darker transition focus:border-desert-green focus:outline-none focus:ring-2 focus:ring-desert-green/20"
className="w-full rounded-lg border border-desert-stone-lighter bg-surface-primary py-2.5 pl-10 pr-4 text-sm text-desert-green-darker transition focus:border-desert-green focus:outline-none focus:ring-2 focus:ring-desert-green/20"
/>
</div>
@ -574,7 +574,7 @@ export default function DrugReferenceIndex({
<button
type="button"
onClick={() => setFiltersOpen((o) => !o)}
className="ml-auto flex items-center gap-1 rounded-full border border-desert-stone-lighter bg-white px-3 py-1 text-xs text-desert-green-darker hover:border-desert-green"
className="ml-auto flex items-center gap-1 rounded-full border border-desert-stone-lighter bg-surface-primary px-3 py-1 text-xs text-desert-green-darker hover:border-desert-green"
>
<IconAdjustmentsHorizontal size={14} />
{route ? ROUTE_FRIENDLY[route] : 'Form'} · {sort === 'name' ? 'AZ' : 'Best match'}
@ -588,7 +588,7 @@ export default function DrugReferenceIndex({
<select
value={route ?? ''}
onChange={(e) => handleRouteChange(e.target.value || null)}
className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
className="rounded-lg border border-desert-stone-lighter bg-surface-primary px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
>
<option value="">Any form</option>
{ROUTE_OPTIONS.map((r) => (
@ -603,7 +603,7 @@ export default function DrugReferenceIndex({
<select
value={sort}
onChange={(e) => handleSortChange(e.target.value as 'relevance' | 'name')}
className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
className="rounded-lg border border-desert-stone-lighter bg-surface-primary px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
>
<option value="relevance">Best match</option>
<option value="name">AZ</option>
@ -704,7 +704,7 @@ export default function DrugReferenceIndex({
<button
type="button"
onClick={() => setBrowseOpen((o) => !o)}
className="flex items-center gap-1 rounded-full border border-desert-stone-lighter bg-white px-3 py-1 text-xs text-desert-green-darker hover:border-desert-olive"
className="flex items-center gap-1 rounded-full border border-desert-stone-lighter bg-surface-primary px-3 py-1 text-xs text-desert-green-darker hover:border-desert-olive"
>
<IconFirstAidKit size={14} />
{anySituationSelected ? 'Add another situation' : 'Pick one or more situations'}
@ -729,7 +729,7 @@ export default function DrugReferenceIndex({
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active
? 'border-desert-olive bg-desert-olive text-white'
: 'border-desert-stone-lighter bg-white text-desert-green-darker hover:border-desert-olive hover:bg-desert-olive/5'
: 'border-desert-stone-lighter bg-surface-primary text-desert-green-darker hover:border-desert-olive hover:bg-desert-olive/5'
}`}
>
{c.label}
@ -977,7 +977,7 @@ function FilterPill({
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active ? activeClass : `border-desert-stone-lighter bg-white text-desert-green-darker ${hoverClass}`
active ? activeClass : `border-desert-stone-lighter bg-surface-primary text-desert-green-darker ${hoverClass}`
}`}
>
{children}

View File

@ -154,7 +154,7 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
{isEmpty ? (
// ── Empty state (no data ingested) ─────────────────────────────────
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<div className="border-2 border-dashed border-border-default rounded-lg p-8 text-center">
<p className="text-lg font-semibold mb-2">No FDA drug data yet</p>
<p className="mb-6 opacity-70">
Download the openFDA drug-label dataset to enable offline search and comparison.
@ -173,9 +173,9 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
) : (
<>
{/* ── Drug picker ───────────────────────────────────────────────── */}
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="mb-6 border border-border-subtle rounded-lg p-4 bg-surface-secondary">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700">Select drugs to compare</h2>
<h2 className="text-sm font-semibold text-text-primary">Select drugs to compare</h2>
{atMax && (
<span className="text-xs text-amber-700 bg-amber-100 border border-amber-200 rounded px-2 py-0.5">
Maximum {MAX_COMPARE} drugs reached
@ -208,7 +208,7 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
.map((id) => (
<span
key={id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-gray-300 text-gray-600 animate-pulse"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-surface-elevated text-text-secondary animate-pulse"
>
#{id}
<button
@ -231,21 +231,21 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
onChange={handleQueryChange}
placeholder="Search for a drug to add…"
disabled={atMax}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-desert-green disabled:bg-gray-100 disabled:text-gray-400"
className="w-full border border-border-default rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-desert-green disabled:bg-surface-secondary disabled:text-text-muted"
/>
{/* Picker results */}
{pickerLoading && (
<div className="mt-2 text-xs text-gray-500">Searching</div>
<div className="mt-2 text-xs text-text-secondary">Searching</div>
)}
{pickerError && (
<div className="mt-2 text-xs text-red-600">{pickerError}</div>
)}
{pickerSearched && pickerResults.length === 0 && !pickerLoading && (
<div className="mt-2 text-xs text-gray-500">No results for "{query}"</div>
<div className="mt-2 text-xs text-text-secondary">No results for "{query}"</div>
)}
{pickerResults.length > 0 && !atMax && (
<div className="mt-2 border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-200 max-h-64 overflow-y-auto bg-white">
<div className="mt-2 border border-border-subtle rounded-lg overflow-hidden divide-y divide-border-subtle max-h-64 overflow-y-auto bg-surface-primary">
{pickerResults.map((r) => (
<PickerRow
key={r.id}
@ -260,19 +260,24 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
{/* ── Comparison columns ────────────────────────────────────────── */}
{selectedIds.length === 0 ? (
<div className="text-center py-12 text-gray-400 border-2 border-dashed border-gray-200 rounded-lg">
<div className="text-center py-12 text-text-muted border-2 border-dashed border-border-subtle rounded-lg">
<p className="text-sm">Select drugs above to compare their labeled interaction warnings.</p>
</div>
) : loadingEntries ? (
<div className="text-center py-12 text-gray-400">
<div className="text-center py-12 text-text-muted">
<p className="text-sm">Loading</p>
</div>
) : (
// Stacked single-column on phones; fixed-min-width columns that
// scroll sideways (never crush) from sm: up, even at MAX_COMPARE.
// Stacked single-column on phones. From sm: up the columns share
// the available width instead of sitting at a fixed 15rem, so one
// selection reads full-width and two split it in half. Label text
// runs long (opioid interaction sections are hundreds of words),
// and a narrow column next to empty space made it near-unreadable.
// min-w-60 keeps the old width as a floor, so at MAX_COMPARE on a
// narrow viewport they still scroll sideways rather than crush.
<div className="flex flex-col gap-3 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:pb-2">
{entries.map((entry) => (
<div key={entry.id} className="w-full sm:flex-none sm:w-60">
<div key={entry.id} className="w-full sm:flex-1 sm:min-w-60">
<InteractionColumn entry={entry} onRemove={removeId} />
</div>
))}
@ -282,7 +287,7 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
)}
{/* ── Source citation (CC0, no-endorsement) ───────────────────────── */}
<footer className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-500">
<footer className="mt-8 pt-4 border-t border-border-subtle text-xs text-text-secondary">
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
endorsed by the FDA. Label data is provided as-is; do not rely on it for medical
@ -305,14 +310,14 @@ interface PickerRowProps {
function PickerRow({ result, selected, onAdd }: PickerRowProps) {
return (
<div className="flex items-center justify-between px-3 py-2 hover:bg-gray-50">
<div className="flex items-center justify-between px-3 py-2 hover:bg-surface-secondary">
{/* Reuse the same visual pattern as DrugResultRow but without a Link */}
<div className="min-w-0 flex-1 mr-3">
<span className="text-sm font-medium text-gray-900 block truncate">
<span className="text-sm font-medium text-text-primary block truncate">
{result.brand_name ?? result.generic_name ?? 'Unknown'}
</span>
{result.brand_name && result.generic_name && (
<span className="text-xs text-gray-500 italic">{result.generic_name}</span>
<span className="text-xs text-text-secondary italic">{result.generic_name}</span>
)}
</div>
{selected ? (

View File

@ -65,7 +65,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
</div>
{label.brand_name && label.generic_name && (
<p className="text-base text-gray-600 italic">{label.generic_name}</p>
<p className="text-base text-text-secondary italic">{label.generic_name}</p>
)}
<dl className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
@ -132,7 +132,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
<Link
key={s.slug}
href={`/drug-reference?situation=${encodeURIComponent(s.slug)}`}
className="rounded-full border border-desert-olive/40 bg-white px-3 py-1 text-sm text-desert-olive-dark transition-colors hover:border-desert-olive hover:bg-desert-olive hover:text-white"
className="rounded-full border border-desert-olive/40 bg-surface-primary px-3 py-1 text-sm text-desert-olive-dark transition-colors hover:border-desert-olive hover:bg-desert-olive hover:text-white"
>
{s.label}
</Link>
@ -176,7 +176,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
)}
{/* ── Footer citation ───────────────────────────────────────────────── */}
<footer className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-500 space-y-1">
<footer className="mt-8 pt-4 border-t border-border-subtle text-xs text-text-secondary space-y-1">
<p>
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
@ -209,10 +209,10 @@ function LabelSection({
}) {
return (
<section className="mb-6">
<h2 className="text-base font-bold mb-2 border-b border-gray-200 pb-1">{title}</h2>
<h2 className="text-base font-bold mb-2 border-b border-border-subtle pb-1">{title}</h2>
<LabelBlocks text={body} />
{footnote && (
<p className="mt-2 text-xs text-gray-500 italic">{footnote}</p>
<p className="mt-2 text-xs text-text-secondary italic">{footnote}</p>
)}
</section>
)