Skip to content

Design System

Design decisions, critical overrides, and UI patterns for the forecasting application. Read before adding UI components.


Default Radix tooltip is blue (bg-primary). Always override to gray. InfoButton handles this automatically. For raw <TooltipContent>, copy the styling from InfoButton’s TooltipContent in src/components/ui/info-button.tsx.

When to use what:

  • <InfoButton> — help/info text next to form fields (handles styling automatically)
  • <TooltipContent> with override — any other tooltip (action labels, status text)

Fields still at defaults get a yellow background via the isDefault prop.

Components with isDefault prop: Input, Select, NumberInput, MonthPicker

Key files: src/components/ui/input.tsx (canonical implementation), src/components/ui/number-input.tsx (delegates to Input)

All dropdown items use focus:bg-muted for hover/focus state. Never use bg-accent (yellow) for menu item highlighting.

Common gotchas:

  • Only Input, Select, NumberInput, and MonthPicker support isDefault — other form components do not
  • NumberInput delegates isDefault to Input internally; it does not implement its own highlight
  • Tooltip gray override is easy to forget on raw <TooltipContent> — prefer InfoButton when possible

Coarse-pointer devices (iPad, etc.) get enlarged tap targets, swapped interaction primitives, and tightened chrome. Two mechanisms cover most cases — prefer the CSS variant; fall back to the hook only when conditional rendering is required.

Custom variants registered in tailwind.config.ts:132:

VariantMedia queryWhen to use
pointer-coarse:(pointer: coarse)Primary input is touch — enlarge tap targets, increase padding
pointer-fine:(pointer: fine)Primary input is mouse
hover-none:(hover: none)Hover is unreliable — hide hover-only affordances
any-pointer-coarse:(any-pointer: coarse)Any available pointer is coarse

Combine with ! to defeat conflicting base classes (e.g., pointer-coarse:!h-11). The tab breakpoint at 900px (tailwind.config.ts:10) is paired with pointer-coarse: to target tablet-class touch layouts (e.g., pointer-coarse:tab:!flex-row). A second custom breakpoint, laptop at 1400px (tailwind.config.ts:11), gates the left-rail SectionNav (hidden laptop:block, src/components/ui/section-nav.tsx:60) — below 1400px the centered w-[75%] main column leaves no gutter for the fixed nav, so it is hidden on every iPad size and shown cleanly on 13”+ laptops.

src/hooks/useIsTouchDevice.tsuseSyncExternalStore wrapper around (pointer: coarse). Use when a CSS variant cannot express the change (component swap, conditional props):

  • InfoButton swaps Tooltip → Popover on touch (src/components/ui/info-button.tsx:48) so taps reliably open the info content.
  • Chart.js options take a literal boolean for things like tooltip caret and pointHitRadius — pass the hook value through.

Initial render returns false for one frame on touch devices, then re-renders with true. Acceptable for a single flash; do not rely on it for SSR-stable markup.

  • Minimum tap target ≈ 44px (Tailwind h-11 / w-11). Apply via pointer-coarse:!h-11 pointer-coarse:!w-11 on icon buttons, Select triggers, scenario tabs, etc.
  • Scroll-edge fade on horizontally-scrollable tables: pointer-coarse:[mask-image:linear-gradient(to_right,black_calc(100%-32px),transparent)] (see Model.tsx, PricingTable.tsx).
  • Wider main column on touch: layout containers use pointer-coarse:!w-[95%] pointer-coarse:!max-w-[1400px] (see Forecasting.tsx, PricingLayout.tsx).
  • Force short legend text: replace verbose labels with shorter ones via paired pointer-coarse:!hidden / pointer-coarse:!inline spans (see guidance-legend.tsx).

Info icon with properly styled tooltip. Supports rich content and optional source citations.

Sources Display: When sources is provided, a dashed divider separates the description from source cards showing name, URL, sample size, and comments.

Alternative Values Display: When alternatives is provided and non-empty, an “Alternative values” section renders below the sources with stacked cards (value + year + per-row sources). Alternatives represent non-guidance reference rows (older years or competing studies) for the same variable — see the guidance cascade in src/features/reports/forecasting/utils/selectGuidance.ts.

Key files: src/components/ui/info-button.tsx (InfoButtonProps interface)

Per-section chart color palette picker. Each chart section has its own independent palette atom.

Palette atoms are defined in src/features/reports/forecasting/atoms.ts (search PaletteAtom). Palette options are defined in CHART_PALETTES in src/features/reports/forecasting/constants.ts.

Key files: src/features/reports/forecasting/components/ColorPaletteSelector.tsx

Standard UI primitives live in src/components/ui/. Use default styling unless this doc says otherwise.

SectionCard supports titleTooltip (renders InfoButton next to title) and headerMeta (content between title and actions, e.g., SimulationCountBadge) — see src/components/ui/section-card.tsx.


Semantic hover colors for icon-only buttons:

  • Edit/Rename — amber hover treatment
  • Delete — destructive hover treatment
  • Info — blue hover treatment

Always include aria-label for accessibility.

For fields with reference data, use useReferenceSource(variableName, lineId?) to get { value, sources, year, alternatives } (the hook applies the guidance cascade — newest year → isGuidance=true). Pass sources and alternatives (plus value as defaultValue) to InfoButton so the tooltip can render both the primary source and any alternative-year rows.

Key files: src/features/reports/forecasting/hooks/useReferenceSource.ts, src/features/reports/forecasting/utils/selectGuidance.ts

When a parent field has editable sub-breakdowns (e.g., Early Stage -> Localized + Locally Advanced):

Pattern: Parent field is read-only (computed from subs). Sub-variables are indented with a left border. RollbackButton sits next to the value input (right side).

Pattern for optional filters that show additional info when active: section header with InfoButton, Switch toggle, and conditional detail block shown when active.

Used in: FirstLineExtras in LinePanel (biomarker filter). See src/features/reports/forecasting/sections/Configuration/tree/panels/LinePanel.tsx.

Inline “Expected to change?” Switch placed immediately after the variable’s InfoButton tooltip (left-hand label cluster), sized as secondary metadata via scale-[0.8] on the Switch and text-[11px] on the label. When ON, the indented growth config (<VariableGrowthConfig>) renders directly below the row with ml-4 border-l-2 border-primary/20 pl-4. The block contains three rows: Annual Growth Rate (%), Maximum Value (%, clamped 0–100), and Start Year for Growth (year dropdown).

Default-on values come from DEFAULT_GROWTH_CONFIG (constants.ts): rate 0%, max 100%, startYear 2030. Toggling OFF clears the companion *Growth row entirely.

Used in: VariableRow (Drug Treatment Rate, custom variables), the SummaryPanel Healthcare Access block (scenario-level), and FirstLineExtras (biomarker Testing Rate). The Transition Rate row hides the toggle (hideGrowthToggle prop) since it does not project. Component: src/features/reports/forecasting/sections/Configuration/tree/panels/VariableGrowthConfig.tsx.

Standalone card inside Patient Flow with Dna icon header. Active sub-fields use indented border guide. Rendered via FirstLineExtras on first-line panels only.

Reference lines show line name as uppercase label, addressable count prominently, and an italic origin footnote linking back to stage totals. Custom lines use an editable name input + delete button + badge instead.

Displayed at the end of Patient Flow for all lines. Uses Users icon header matching the Biomarker card pattern. Shows eligible count prominently.

Layout: each row is a 2-column CSS grid with grid-template-columns: var(--pf-rail-w) 1fr (44px switch rail | 1fr trapezoid area). The trapezoid is shaped via clip-path: polygon() parameterized by --top-w and --bot-w custom properties (percentages). The switch rail has a 1px --ink-200 vertical guide line drawn via a ::after pseudo-element behind the switch.

Palette: CHART_PALETTES["indigo-flow"] (7-shade cascade, 700→100). The ID indigo-flow is retained for back-compat with persisted palette selections, but the values were shifted to Tailwind blue and the user-facing display name is Blue Flow. Top-of-funnel rows use the darker shades; bottom-of-funnel and custom rows use the lighter shades. Early-stage row uses the two lightest shades with --primary-deep text; Metastatic rows use white text on the gradient.

Tooltips: built on the existing <InfoButton> component (@/components/ui/info-button) which renders a lucide Info icon and delegates hover/touch to Radix Tooltip / Popover. <FlowTail> consumes it with rich source citations from useReferenceSource("transitionRate", lineId), where lineId is the receiving (downstream) line — transitionRate stores the rate INTO that line, so citations resolve to its reference row.

States:

StateVisual
DefaultTrapezoid with palette gradient, full opacity, switch ON; switch rail shows an “Included” / “Excluded” label above the toggle
Hover (not active)filter: brightness(1.06) on the trapezoid; row border lifts to color-mix(in srgb, var(--primary-deep) 80%, transparent)
Activetransform: translateY(-1px), white --card background, --primary-deep border, drop shadow (box-shadow: 0 4px 6px -1px / 0 2px 4px -2px)
Deselected (data-selected="false")filter: saturate(0.25) opacity(0.55) on the trapezoid; label dimmed; in-row count replaced by ; switch-rail label shows “Excluded”

Typography: Fraunces (display) for phase labels and population pill disease name; JetBrains Mono for all figures/counts/transitions/tags; Public Sans for everything else.

Animation: entrance via pf-flow-in keyframe (opacity-only, so it doesn’t pin the active row’s translateY(-1px) transform) + nth-child delays, plus count transitions via useCountScrub hook (cubic ease-out). Both are gated on prefers-reduced-motion: no-preference.


Tab bar in the sticky dropdown bar allowing up to MAX_SCENARIOS scenario tabs per model. Uses role="tablist" with ARIA attributes.

Tab states: Active tab has a “connected tab” effect (bottom border masked by background color). Inactive tabs show muted text with hover treatment.

Dirty dot indicator (VS Code pattern): Small amber dot positioned left of the tab label. Has role="img" and aria-label="Unsaved changes" when active.

Tab rename: Triggered by double-click on label, pencil icon click, or F2 key. Enter or blur confirms, Escape cancels.

Action elements:

  • Pencil icon (active tab only) — amber hover
  • Close button — destructive hover, hidden when only one tab remains
  • ”+” button — dashed border, opens popover with “New scenario” and “Duplicate current”
  • Counter — shows N / MAX_SCENARIOS

Key files: ScenarioTabBar, ScenarioTab, AddScenarioButton in src/features/reports/forecasting/scenarios/


Scenario Color System: Colors assigned by tab index from SCENARIO_COLORS in src/features/reports/forecasting/constants.ts. Colors wrap via index % SCENARIO_COLORS.length.

Legend Bar (sticky): The ComparisonFilterRow is the sticky top bar on the comparison page. Each scenario is rendered as a color-coded chip with checkbox toggle. Minimum 2 must remain selected — the toggle is a no-op if deselecting would drop below 2.

Compare Tab: Uses same active/inactive tab styling as scenario tabs but with a Columns3 icon prefix. Disabled when fewer than 2 tabs with tooltip “Open 2 or more scenarios to compare”.

Side Navbar: Comparison page reuses SectionNav with COMPARISON_SECTIONS from src/features/reports/forecasting/section-config.ts (5 sections: Summary, Net Sales, Monte Carlo, Model Totals, Sensitivity). The Monte Carlo section card is titled Forecasting Comparison on the page (the navbar label still reads “Monte Carlo”).

Per-Section Export: Each SectionCard shows an ExportButton dropdown with Image + CSV options. Chart.js sections (Sales, Monte Carlo) export via toBase64Image; DOM sections (Model Totals, Tornado) use html-to-image. All export buttons are disabled while Monte Carlo simulations are running.

Export Report: An “Export Report” button in ComparisonFilterRow opens a section selector popover (matching the main export’s UI) and generates a DOCX comparison report with only the selected sections.

Key files: src/features/reports/forecasting/sections/Comparison/

Common gotchas:

  • View mode is plain React state (activeView union: "editor" | "comparison" | "pricing"), not Jotai atoms — avoids entangling with per-scenario stores
  • ComparisonFilterRow (not “LegendBar”) is the actual component name
  • MC and tornado data are nullable per scenario — each visualization gracefully handles missing data
  • Minimum 2 scenarios enforced at three levels: CompareTab disabled, ComparisonPage placeholder, filter row checkbox constraint
  • Scenario colors are -500 Tailwind shades (softer palette) — not -700/-800

Scoped CSV export dropdown: Export CSV in PricingFilterRow is a DropdownMenu with two items — Selected filters (N) and All data (N) — so the user picks scope. “Selected filters” is disabled when filteredData.length === 0 or when it equals allData.length (no filter active → would duplicate “All data”). Filename: drug-pricing-<indication-slug>.csv or drug-pricing-filtered.csv for the filtered export; drug-pricing-all.csv for the full dataset.

CSV columns mirror the table: when the WAC-to-Net Discount toggle is on, Discount % and Monthly Net ($) are inserted after Monthly WAC ($) in both exports.

Key files: src/features/reports/forecasting/sections/Pricing/


A second token layer added in addition to the original Bioloupe palette. These tokens use raw hex/rgba values (not HSL like the original tokens) because they are intended for direct CSS-variable use in custom components, not for Tailwind utility integration.

Currently used by: PopulationSummaryCard (StageRow color), SubStageGroup (indent guide), and the right-panel surfaces. The patient flow funnel itself reads colors from CHART_PALETTES["indigo-flow"] in src/features/reports/forecasting/constants.ts, not from these tokens.

Future expansion: Other surfaces (right-panel ConfigPanel, SummaryPanel, LinePanel) inherit progressively as they get touch-ups. Do not roll out tokens to unrelated surfaces in unrelated phases.

TokenValueUse
--surface-0#f8fcffPage background (matches existing --background)
--surface-1#ffffffCard / elevated surface
--surface-2#f1f5f9Recessed / hover surface
TokenValueUse
--section-header-neutral#f1f5f9Neutral section header tint
--section-header-primaryrgba(0, 32, 96, 0.05)Primary-themed section header tint

Stage tokens (legacy — patient flow uses the indigo-flow palette)

Section titled “Stage tokens (legacy — patient flow uses the indigo-flow palette)”

The patient flow funnel encodes stage progression by position, not by stage-specific color. Trapezoid fills come from CHART_PALETTES["indigo-flow"] (src/features/reports/forecasting/constants.ts) and are not user-configurable. The user-configurable chart atoms (salesPaletteAtom, monteCarloPaletteAtom, tornadoPaletteAtom) default independently — sales and Monte Carlo to "monochrome", tornado to "vibrant" (downside [0] red / upside [3] green).

A small subset of the original DS v2 stage tokens still has active consumers:

TokenActive consumer
--stage-early-textPopulationSummaryCard.StageRow — Early and Metastatic stage rows both use this color (unified)
--stage-early-line-softSubStageGroup vertical indent guide

The remaining tokens (--stage-early-{band, bg-soft, line, active-bg, active-glow} and all --stage-met-*) are dormant in globals.css for cheap rollback if a stage-color UI is reintroduced. They currently have no consumers.

Color contract: the funnel is the visual encoding of stage progression. Do not revive amber or introduce new stage-specific accent tokens.

<div style={{ background: "var(--stage-early-band)" }}>
<span style={{ color: "var(--stage-early-text)" }}>Early Stage</span>
</div>