# Info Popover — unified metric-help affordance

**Date:** 2026-07-22
**Status:** Approved (design)

## Problem

Two different "info affordance" patterns explain metrics across the app, and both are broken:

1. **`MetricTooltip` (ⓘ)** — used on the two detail pages (`SchoolDetailView`,
   `SecondarySchoolDetailView`). Its bubble is `position: absolute` with a fixed
   `220px` width and no viewport-collision detection. Near a screen edge on
   mobile the bubble renders **partly or wholly off-viewport with no way to
   scroll to it** — effectively unusable. A `left: -12px` mobile hack only
   shifts the problem, it doesn't solve it.

2. **Compare-section `?` help** — every compare section funnels through
   `RowLabel` in `components/compare/sectionShared.tsx`, which uses the **native
   `title=` attribute**. On desktop the native tooltip has a long, unconfigurable
   hover delay; on touch it barely surfaces at all.

We want a single component that positions itself correctly in any viewport and
shows a custom (non-native) tooltip on desktop.

## Decisions

- **Positioning: `@floating-ui/react`** (industry standard, ~10KB gzipped, React
  19 compatible). Chosen over a hand-rolled portal + JS positioning because the
  current hand-rolled approach is exactly what failed, and Floating UI already
  solves flip/shift/portal/interactions/ARIA.
- **Mobile presentation: repositioning popover** (not a bottom-sheet). Same small
  bubble as desktop; Floating UI's `shift`/`flip` keep it fully on-screen. One
  presentation to build and maintain, consistent across platforms.
- **Glyph: standardise on the circled `?`** everywhere (replaces ⓘ on the detail
  pages). Matches the common "help" convention.
- **Bundle:** adding `@floating-ui/react` as a runtime dependency is accepted.

## Architecture

One shared engine, two thin adapters — **no call-site churn**.

### `InfoPopover` (new — `components/InfoPopover.tsx`)

Owns all behaviour via `@floating-ui/react`.

- **Trigger:** a real `<button>` (min 24px tap target — WCAG 2.5.8 —
  keyboard-focusable) showing the circled `?` glyph. Muted colour; hover / focus
  / open → accent colour.
- **Positioning middleware:** `offset(6)` + `flip()` + `shift({ padding: 8 })`
  so the bubble flips above/below and slides sideways to **always stay fully
  on-screen** (the direct fix for the off-viewport bug). An `arrow()` element
  tracks the trigger.
- **Portal:** rendered inside a `FloatingPortal` so it escapes the compare
  grid's `overflow`/`transform` clipping contexts.
- **Interactions** (merged via `useInteractions`, one code path for both
  platforms):
  - `useHover(context, { delay: { open: 100, close: 0 } })` — short open delay
    fixes the slow native-`title` hover; Floating UI disables hover on touch
    devices so it doesn't double-fire with tap.
  - `useFocus` — keyboard focus reveals it.
  - `useClick` — tap toggles on touch.
  - `useDismiss` — outside-press and `Escape` close it (opening another popover
    closes the first, since the outside-press lands on the new trigger).
  - `useRole(context, { role: 'tooltip' })` — ARIA wiring.
- **Content:** optional `label` (bold), `plain` (body), `detail` (muted) — same
  content shape as today.

**Props:**

```ts
interface InfoPopoverProps {
  label?: string;   // bold heading
  plain?: string;   // body text (the primary explanation)
  detail?: string;  // muted supplementary line
  /** Accessible name for the trigger button, e.g. the metric label. */
  ariaLabel?: string;
}
```

Renders `null` when there is no `plain` content (mirrors current
`MetricTooltip` behaviour).

### Adapter 1 — `MetricTooltip` (public API unchanged)

Keeps its existing props (`metricKey`, `label`, `plain`, `detail`), still
resolves `metricKey` → `METRIC_EXPLANATIONS`, and renders `InfoPopover` with the
resolved content. **All ~24 detail-page call sites are untouched.** The
hand-rolled positioning/dismiss `useEffect` and the `.tooltip` absolute-position
CSS (including the `left: -12px` mobile hack) are deleted.

### Adapter 2 — `RowLabel` (in `sectionShared.tsx`)

Replaces the native-`title` `?` span with `InfoPopover`, passing the `tip`
string as `plain` and the row label as `ariaLabel`. **All ~14 compare `tip=`
call sites are untouched** (they pass through `Measure` → `RowLabel`).

## Removed / replaced

- `MetricTooltip`'s hand-rolled open/dismiss `useEffect`.
- `MetricTooltip.module.css` `.tooltip` absolute positioning + arrow + the
  `@media (max-width: 640px)` `left: -12px` hack (superseded by Floating UI).
- Native `title=` on the compare `?` help span.

## Testing

1. **Unit (jest + jsdom)** — `InfoPopover`:
   - trigger button renders with the correct `aria-label` and `aria-expanded`;
   - click opens and closes it;
   - `label` / `plain` / `detail` render when open;
   - `Escape` closes it.
   Positioning (flip/shift) is Floating UI's own tested concern, not re-tested
   here.

2. **E2E (Playwright, mobile viewport)** — the regression guard for the reported
   bug. On a narrow viewport, open a help popover near the **right edge** of a
   compare section and assert its bounding box is fully within the viewport
   (`rect.right <= innerWidth && rect.left >= 0`). This is the check that would
   have caught the original overflow. Satisfies the CLAUDE.md rule to extend
   `e2e/` when user-facing behaviour changes.

## Out of scope

- Other `title=` usages (button/link hints, empty-state props) — these are not
  metric explainers and keep native `title`.
- No bottom-sheet / alternate mobile presentation.
