Design System
Design System
Section titled “Design System”Design decisions, critical overrides, and UI patterns for the forecasting application. Read before adding UI components.
Table of Contents
Section titled “Table of Contents”- Critical Overrides
- Touch Device Support
- Reusable Components
- Patterns
- Scenario Tab Bar
- Comparison Page
- Pricing Page
- Standard of Care Drawer
Critical Overrides
Section titled “Critical Overrides”Tooltips - Gray Background Required
Section titled “Tooltips - Gray Background Required”Default Radix tooltip is blue (bg-primary). Always override to gray. InfoButton handles this automatically (it is a Popover, not a Tooltip — see InfoButton). For raw <TooltipContent>, apply bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 (working reference: the VariableRow clamp-indicator <TooltipContent> in LinePanel.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)
Clamp Indicator (forecasting line panel)
Section titled “Clamp Indicator (forecasting line panel)”When a percent field in a VariableRow receives a value outside 0–100, it is clamped and an amber AlertTriangle (role="img", aria-label="Value clamped to 0–100%") appears beside the input with a gray-override <TooltipContent> explaining the clamp. The indicator is local row state (valueClamped) that auto-clears when the field’s value next changes from any other source (rollback, line switch) — not only on the user’s own edit.
Key file: src/features/configuration/tree/panels/LinePanel.tsx (VariableRow)
Stage Clamp Banner (forecasting summary panel)
Section titled “Stage Clamp Banner (forecasting summary panel)”When Early Stage % + Metastatic % exceeds 100% in the solid-tumor stage block, SummaryPanel renders an amber AlertTriangle banner above the stage inputs explaining that Unknown is pinned to 0% and the split is renormalized — surfacing the math the forecast funnel already applies (see FORECASTING_MODEL.md Unknown Stage Redistribution). The threshold uses two-decimal float-rounding so an exact 100 does not trip it.
Key file: src/features/configuration/tree/panels/SummaryPanel.tsx
Default Value Highlighting
Section titled “Default Value Highlighting”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)
Dropdown/Select Hover States
Section titled “Dropdown/Select Hover States”All dropdown items use focus:bg-muted for hover/focus state. Never use bg-accent (yellow) for menu item highlighting.
A per-option at-rest tint is permitted (the indication picker tints custom entries with bg-secondary — purple, not the banned bg-accent yellow); the bg-muted hover/active highlight still wins the background, so the tint yields on the active row while a non-background marker (e.g. border-l-2) persists.
Common gotchas:
- Only
Input,Select,NumberInput, andMonthPickersupportisDefault— other form components do not NumberInputdelegatesisDefaulttoInputinternally; it does not implement its own highlight- Tooltip gray override is easy to forget on raw
<TooltipContent>— preferInfoButtonwhen possible
Touch Device Support
Section titled “Touch Device Support”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.
Tailwind variants
Section titled “Tailwind variants”Custom variants registered in tailwind.config.ts:132:
| Variant | Media query | When 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.
Conventions
Section titled “Conventions”- Minimum tap target ≈ 44px (Tailwind
h-11/w-11). Apply viapointer-coarse:!h-11 pointer-coarse:!w-11on 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)](seeModel.tsx,PricingTable.tsx). - Wider main column on touch: layout containers use
pointer-coarse:!w-[95%] pointer-coarse:!max-w-[1400px](seeForecasting.tsx,PricingLayout.tsx). - Force short legend text: replace verbose labels with shorter ones via paired
pointer-coarse:!hidden/pointer-coarse:!inlinespans (seeguidance-legend.tsx).
Reusable Components
Section titled “Reusable Components”InfoButton
Section titled “InfoButton”Info icon backed by a single controlled Popover. Hover (or keyboard focus) peeks after a short settle delay (HOVER_OPEN_DELAY_MS, 700ms — matching the pre-Popover Radix Tooltip default — so a quick graze of a dense panel does not flash it open); clicking pins it open INSTANTLY so the user can move into the content to click source links or scroll. An unpinned hover-peek closes on mouse-leave and never moves focus, so reading source links requires a click to pin first (by design). On the pinned-open edge, focus moves into the content’s first focusable element (WCAG 2.1.1 keyboard reachability), and Escape restores focus to the trigger. Esc and outside-interaction dismiss. Hover handlers are inert on touch (no hover events fire), so tap → the click-to-pin path. The state machine lives in useInfoButtonPopover (src/components/ui/useInfoButtonPopover.ts); the component is a thin Popover wrapper. 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/core/state/utils/selectGuidance.ts.
Key files: src/components/ui/info-button.tsx (InfoButtonProps interface)
ColorPaletteSelector
Section titled “ColorPaletteSelector”Per-section chart color palette picker. Each chart section has its own independent palette atom.
Palette atoms (salesPaletteAtom, monteCarloPaletteAtom, tornadoPaletteAtom) are defined in src/core/state/primitives/context-keys.ts. Palette options are defined in CHART_PALETTES in src/core/types/constants.ts.
Key files: src/core/ui/ColorPaletteSelector.tsx
Combobox
Section titled “Combobox”Searchable single-select dropdown — a trigger button plus a popover with a filter input and a keyboard-navigable list. Built on Radix Popover + Input (no cmdk dependency). Use instead of Select when the option list is long enough to want type-to-filter search — the indication selector in Forecasting.tsx is the reference consumer. Case-insensitive substring matching; arrow keys + Enter, mouse, and click selection; WAI-ARIA combobox roles. Highlight uses bg-muted (per the Dropdown/Select Hover States override above).
An optional optionClassName?: (option: string, isActive: boolean) => string | undefined hook lets a consumer style specific option rows per state; it is composed after the active-highlight class, so a consumer can override bg-muted on the active row (return the active-state classes only when isActive). The indication picker uses it to mark custom-indication entries with a calm navy-leaning left border at rest that blooms to the accent (bg-secondary text-primary) when the row is active — mirroring the PlaceholderCreateActions create chips.
An optional header?: React.ReactNode slot renders pinned content between the search input and the scrolling list. It sits outside the scroll container, so it stays visible while the user filters (the query never affects it). The indication picker uses it for the placeholder-indication create action bar (PlaceholderCreateActions): the two “create” entries live here as side-by-side chips rather than as filterable list rows.
Key files: src/components/ui/combobox.tsx (ComboboxProps interface)
Other Available Components
Section titled “Other Available Components”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.
Patterns
Section titled “Patterns”Icon Action Buttons
Section titled “Icon Action Buttons”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.
Form Field with Reference Sources
Section titled “Form Field with Reference Sources”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/core/ui/useReferenceSource.ts, src/core/state/utils/selectGuidance.ts
Incidence year labels (unified on the projection year): both the editable SummaryPanel and the read-only blue PopulationSummaryCard prefix the incidence label with the current projection year (currentYearAtom) — e.g. “2026 Incidence” — and both render the same stored base count (incidenceBaseOverride ?? diseaseConfig.incidence.count). The source publication year (incidenceGuidance.year) is no longer a standalone label; it appears only inside the ⓘ tooltip’s source card (passed as guidanceYear). The two tooltips share one sentence via incidenceTooltipText(currentYear) (src/features/configuration/tree/incidenceTooltipCopy.tsx), with the year shown as an italic parenthetical. (Re-leveling the count to the current year via anchorIncidenceEvolution happens only downstream — the IncidenceEvolution curve and the forecast it feeds — never on these two summary labels.)
Nested Sub-Variable Fields
Section titled “Nested Sub-Variable Fields”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).
Toggle Filter with Conditional Details
Section titled “Toggle Filter with Conditional Details”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/configuration/tree/panels/LinePanel.tsx.
Expected to Change Toggle
Section titled “Expected to Change Toggle”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 (%), Target Value, and Start Year for Growth (year dropdown). Target Value is unit-aware: it defaults to a percentage (suffix %, clamped 0–100) for percentage hosts, but accepts optional maxCeiling / unitSuffix props — Months of Therapy passes maxCeiling={MAX_MONTHS_OF_THERAPY} (300) and a mo suffix so the cap is neither mislabeled nor truncated to 100.
Negative rate makes the Target Value a floor (the label does not change): the bound input is always labeled “Target Value”, but its role flips with the rate’s sign — with a non-negative rate it is a CEILING, and when the Annual Growth Rate is negative it acts as a FLOOR the decaying value cannot drop below. The engine role is sign-driven (config.min < 0 in VariableGrowthConfig); only the on-screen label was collapsed to the single “Target Value” string. A declining variable left at the default bound reads “Target Value: 0.00%”, because displayMax mirrors the math-layer floor for display (config.min < 0 ? (config.max >= maxCeiling ? 0 : min(config.max, baseValue)) : config.max) — a floor stranded above a later-lowered base likewise shows the base, matching the engine’s compute. onChange still spreads the raw config, so the normalized value is never written back to storage.
Floor-above-base is rejected at the input boundary (required baseValue prop): every render site (LinePanel, SummaryPanel, FirstLineExtras, VariableRow) passes the host variable’s current base value. While the rate is negative the Target Value input is hard-clamped to min(maxCeiling, baseValue) — a floor cannot sit above the host’s base, or a “declining” variable would rise UP to the floor and pin flat instead of decaying. When the rate sign flips (crosses 0 in either direction) the bound resets to max(0, min(maxCeiling, baseValue)), so a stale ceiling can’t be reinterpreted as a floor above base on flip-to-negative (nor a stale floor as a ceiling on flip-to-positive). A same-sign rate edit (e.g. −5 → −3) is NOT a flip and preserves the bound (the write-back-safety invariant). The input boundary is the first line of defense, not the only one: as a backstop, the engine (applyChangeableValue) also caps the negative-rate floor at the live baseValue, covering the snapshot-restore / API-fed / direct-base-edit paths the input clamp can’t observe (see FORECASTING_MODEL.md §Growth Over Time).
Default-on values come from DEFAULT_GROWTH_CONFIG (constants.ts): rate 0%, max 100%, startYear 2030. Months of Therapy instead seeds DEFAULT_MONTHS_GROWTH_CONFIG (same rate/startYear, cap 300) so a long duration is not silently clamped down. Toggling OFF clears the companion *Growth row entirely.
Used in: VariableRow (Drug Treatment Rate, Transition Rate, custom variables), the SummaryPanel Healthcare Access block (scenario-level), and FirstLineExtras (biomarker prevalence and testing rate). Component: src/features/configuration/tree/panels/VariableGrowthConfig.tsx.
Biomarker Card
Section titled “Biomarker Card”Standalone card inside Patient Flow with Dna icon header. Active sub-fields use indented border guide. Rendered via FirstLineExtras in one of two modes (branched on isStageFirstLine): definition mode on the stage-first selected line (which defines the per-stage type/values but no longer force-applies them), and override mode on every non-stage-first line while a biomarker is active. Stage-first lines edit the biomarker definition fields (prevalence, testing rate, growth) and see an italic hint beneath the testing rate pointing users to each line’s own card to disable it. Non-stage-first lines seed those same controls from the per-line resolved cascade (biomarkerByLineAtom) and write per-line overrides; the per-line on/off Apply Switch lives in the card header on every line including stage-first (not in InheritedFiltersPanel, which now carries only the transplant and upstream-custom Switches). The header Switch is always rendered — turning a line’s biomarker OFF reverts that line’s biomarker factor to 1.0 (the line falls back to the un-gated pool), so the user can toggle it back ON. On a non-stage-first line, OFF also collapses the card body to just that header; the stage-first card body stays visible (it is the stage-definition card).
LinePanel Header
Section titled “LinePanel Header”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.
Drug Treated Patients Card
Section titled “Drug Treated Patients Card”Displayed at the end of Patient Flow for all lines. Uses Users icon header matching the Biomarker card pattern. Shows eligible count prominently.
Patient flow funnel (V3)
Section titled “Patient flow funnel (V3)”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:
| State | Visual |
|---|---|
| Default | Trapezoid 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) |
| Active | transform: 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.
Model Table — Per-Line Driver Breakdown
Section titled “Model Table — Per-Line Driver Breakdown”Every therapy-line row in the Model section table can expand an inline, per-line driver breakdown above its “Drug Treated Patients” row.
Per-line chip (ModelTableRow.tsx). A compact ghost Button (size="sm", h-6 … text-xs) sits above the result matrix, styled gray to match the table cells (bg-muted, bordered, muted foreground). Its label toggles between “Show all assumptions” (closed) and “Hide all assumptions” (open), with a paired ChevronDown / ChevronUp and the title “Show/Hide all assumptions for this line”. It carries aria-expanded + aria-controls pointing at the breakdown wrapper. There is no global “Expand all” checkbox — the per-line chip is the only control.
Inline driver rows (DriverChain.tsx). Opening the chip injects the breakdown as inline Model-table rows that mirror ModelTableRow’s flex/cell geometry — label column min-w-[160px], year columns min-w-[100px], tabular-nums cells with a border-l between years — so values align under the same year headers. The rows are glyph-free (values only; no × / − / = operator prefixes): percents truncate to 1dp (Math.trunc(value * 10) / 10, never rounding up — the same convention as <FlowTail> in the Patient flow funnel) and counts are comma-grouped. The terminal drugTreatedPatients step is dropped here; the existing “Drug Treated Patients” row stays the bottom-line result anchor the chain feeds into.
Seam spacing. The breakdown wrapper (id="model-row-chain-<lineId>", where <lineId> is stage-prefixed, e.g. met1) carries mb-2 to reproduce the table’s gap-2 (8px) rhythm at the breakdown → Drug Treated Patients seam. The wrapper only mounts while open, so the closed state and chip spacing are untouched.
“Retained on prior line” tooltip. When the chain includes a “Retained on prior line” row, an InfoButton renders beside its label explaining the re-treatment / eligibility-pool subtraction. The full semantics live in FORECASTING_MODEL.md — the tooltip is the surfacing affordance, not the source of truth.
Distinct from the same-named Drug Treated Patients Card, which is the Users-icon card in the Configuration Patient Flow funnel.
Key files: src/features/model/ModelTableRow.tsx (chip), src/features/model/DriverChain.tsx (breakdown rows).
Scenario Tab Bar
Section titled “Scenario Tab Bar”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/app/
Comparison Page
Section titled “Comparison Page”Scenario Color System: Colors assigned by tab index from SCENARIO_COLORS in src/core/types/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/app/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; the Monte Carlo export button is also disabled when any compared scenario has stale MC results.
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.
Combined Total: The Net Sales card header carries a “Show combined total” Switch (default ON, ephemeral useState — not persisted) that overlays a per-year cross-scenario net-sales sum as an emphasized line above the grouped bars. The Model Totals table shows a matching always-on “Combined total” row. Both use COMBINED_TOTAL_COLOR (= CHART_COLORS.text.primary, #1f2937) for the line and the row dot — deliberately the dark ink primary, not a SCENARIO_COLORS shade, so the aggregate reads as distinct from the per-scenario series. The DOCX chart is WYSIWYG (toggle OFF ⇒ no line in the exported chart; the table row is unaffected).
Key files: src/features/comparison/
Common gotchas:
- View mode is plain React state (
activeViewunion:"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
Pricing Page
Section titled “Pricing Page”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 Gross-to-Net Discount toggle is on, Discount % and Monthly Net ($) are inserted after Monthly List Price ($) in both exports.
Key files: src/features/pricing/
Standard of Care Drawer
Section titled “Standard of Care Drawer”Slide-based reference drawer surfacing the standard-of-care diagram(s) for the active indication. Mounted as a persistent (always-in-tree) sibling of the indication selector in Forecasting.tsx — it self-hides, rendering nothing when the active indication has no slides; implementation lives in src/features/standard-of-care/.
Indication-label matching is normalized identity, never substring. normalizeIndicationLabel (manifest.ts) strips a single trailing parenthetical so backend abbreviation suffixes (e.g. Small Cell Lung Cancer (SCLC)) still resolve to manifest keys. Lookups go through a NORMALIZED_MANIFEST Map keyed by the normalized label, built once at module load — it throws on a normalized-key collision, so two manifest entries that normalize to the same key fail loudly at import rather than silently shadowing each other.
Title rail (clickable slide stepper). slideViewsForIndication(indication) returns the ordered slides for an indication, each decorated with a rail title from SOC_SLIDE_TITLES (keyed by slide src). The rail replaced the old N / M position counter: a 2+ slide indication renders one clickable title per slide (the active title scrolls into view), while a single-slide indication shows a static heading. The rail is live-reachable for any indication with 2+ slides (e.g. SCLC, Prostate, Pancreatic, and all four breast subtypes).
Title-at-slide, caption-at-membership (many-to-many). A slide’s title is intrinsic to the slide and stays constant in every drawer that shows it. A slide’s caption is per-(indication, slide) membership — the same slide can be referenced under multiple breast-subtype indications (Triple-Negative, HER2-Positive, HER2-Negative, Hormone Receptor-Positive), each with its own caption, while the rail title does not change. All four breast subtype indications are live in the production indication dropdown.
Accessibility — role="group" + aria-current, not ARIA tabs. The rail uses role="group" with aria-current on the active title — the same aria-current convention as SectionNav (src/components/ui/section-nav.tsx), but a plain group rather than the ARIA tabs (role="tablist") used by the Scenario Tab Bar, because the rail selects content in place rather than exposing tab panels. The slide image carries a descriptive alt ("Standard-of-care reference diagram — {rail title}", WCAG 1.1.1) rather than a bare label. When zoomed past fit the slide viewport becomes a keyboard-focusable, labelled scroll region (static role="group" carrying a tabIndex={0} + aria-label that appear only while zoomed — WCAG 2.1.1 scrollable-region-focusable) so keyboard-only users can pan; group is non-landmark, so the unnamed at-fit state adds no rotor noise. Three scoped // biome-ignore comments (useSemanticElements on the rail group and on the zoom viewport, useExhaustiveDependencies on the scroll-into-view effect) document the justified exceptions in StandardOfCareDrawer.tsx.
Adding a new indication’s slides. The manifest is hand-authored — a new indication never lights up automatically, and an indication with no manifest entry self-hides (no trigger button, no empty state). Do all of the following in one commit so the guard tests in manifest.test.ts stay green:
- Drop the slide asset(s) into
public/standard-of-care/assoc-NN.svg. Themanifest.test.tssrcguard only accepts the committedsoc-NNrange (soc-01–soc-30); add a slide past it and you must widen that regex in the same commit. (soc-12is a deliberate orphan — referencing it trips a separate guard.) - Add a manifest entry to
STANDARD_OF_CARE_MANIFEST(manifest.ts). The key must be the exact backend indication label from data-govAnalyticsDiseases::IDS_TO_LABELS(the stringindicationAtomholds), modulo a trailing(ABBR). The value is the ordered[{ src, caption }]list, wherecaptionis the per-(indication, slide) membership label. - Add a rail title for each new slide to
SOC_SLIDE_TITLES(manifest.ts), keyed bysrc. Every referenced slide must have a non-empty title, and titles must be unique within a single indication’s slide set — both are guarded. - Add the label to the drift fixture
EXPECTED_SOC_LABELS(expected-labels.ts) with the backendid+ verbatim label, and bump the exactly-N count assertions inmanifest.test.tsthat pin the current key/label count. Without this the no-drift tests fail. - Run
pnpm test(the pre-commit hook also runsvitest related --runon the stagedmanifest.ts).
The indication must already exist in the backend taxonomy — slides for a label data-gov never serves are dead weight, since the drawer only ever sees indicationAtom’s backend string. Slides are derived from the source PDF: page N → soc-NN.svg (manifest.ts).
Key files: src/features/standard-of-care/ (StandardOfCareDrawer.tsx, manifest.ts, expected-labels.ts, manifest.test.ts)
Design System v2 (additive, 2026-05-03)
Section titled “Design System v2 (additive, 2026-05-03)”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/core/types/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.
Surfaces
Section titled “Surfaces”| Token | Value | Use |
|---|---|---|
--surface-0 | #f8fcff | Page background (matches existing --background) |
--surface-1 | #ffffff | Card / elevated surface |
--surface-2 | #f1f5f9 | Recessed / hover surface |
Section headers
Section titled “Section headers”| Token | Value | Use |
|---|---|---|
--section-header-neutral | #f1f5f9 | Neutral section header tint |
--section-header-primary | rgba(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/core/types/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:
| Token | Active consumer |
|---|---|
--stage-early-text | PopulationSummaryCard.StageRow — Early and Metastatic stage rows both use this color (unified) |
--stage-early-line-soft | SubStageGroup 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.
Usage example
Section titled “Usage example”<div style={{ background: "var(--stage-early-band)" }}> <span style={{ color: "var(--stage-early-text)" }}>Early Stage</span></div>