# Responsive & input

> The details matter most here. A financial interface has more content than any other product category — twelve columns, four periods, nested hierarchy — and it has to be fully usable on a phone held one-handed on a train and on a 13" laptop with a trackpad.

Source: https://lopifi.com/docs/craft/04-responsive-and-input
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Responsive & input

The details matter most here. A financial interface has more content than any other product category — twelve columns, four periods, nested hierarchy — and it has to be fully usable on a phone held one-handed on a train and on a 13" laptop with a trackpad.

The rule that governs everything below:

> **Financial UI doesn't reflow. It re-ranks.** Deciding *which three of eleven columns matter* on a 390px screen is a design decision that must be made per component. `overflow-x: auto` is not that decision — it's the absence of one.

---

## Breakpoints

```
xs     0      phone portrait, 320–479        4-col grid, 16px margin
sm   480      phone landscape, small tablet  4-col
md   768      tablet portrait                8-col, 24px margin
lg  1024      LAPTOP — the primary target    12-col
xl  1280      desktop                        12-col
2xl 1536      large desktop                  12-col, max 1440 centered
```

**`lg` (1024–1440) is the design target**, not `2xl`. Finance teams work on 13–14" laptops, frequently at 125% OS scaling and often with a spreadsheet in the other half of the screen. A dashboard designed at 1920 and "made responsive" is a dashboard that has never been used at its real width.

Design at **1280 and 390 first.** Everything else is interpolation.

### Container queries over media queries
A card doesn't know the viewport; it knows its own width. Use container queries so the same `BalanceCard` works in a 12-col hero and a 3-col sidebar without a variant prop.

```css
.card { container-type: inline-size; container-name: card; }
@container card (max-width: 320px) { .figure { font-size: 20px; line-height: 28px; } }
```

Media queries stay for **page layout and input modality**. Container queries for **component internals**. That split is the whole responsive architecture.

---

## Touch targets

```
Minimum       44 × 44 CSS px   (WCAG 2.5.5 AAA / Apple HIG)
Comfortable   48 × 48
Spacing       ≥ 8px between adjacent targets
```

Our default 40px control satisfies this via **hit-slop** — the visual box is 40px, the touch box is 44+:

```css
.target-slop::after {
  content: "";
  position: absolute;
  inset: -4px;          /* 40 → 48 */
}
```

Visual density stays tight; the target doesn't. This is the technique that lets a compact table row still have tappable actions.

**Never shrink a target to fit.** If three actions don't fit at 44px, show one and put the rest in an overflow menu.

### Thumb zones (phone, one-handed)
On a 390 × 844 screen held in one hand, the bottom ~40% is comfortable, the top ~25% requires a grip shift.

| Zone | Put here |
|---|---|
| Bottom 0–35% | Primary actions, tab bar, sheet confirm buttons, keypad |
| Middle 35–70% | Content, the values being read |
| Top 70–100% | Title, back, secondary/destructive actions, filters |

Consequences that are actually enforced:
- **A payment's confirm button is bottom-anchored**, above the safe area, always visible — never at the bottom of a scrolling form where it can be missed.
- **Destructive actions live at the top**, deliberately out of thumb reach.
- **Sheets beat dialogs on phones**, always. A sheet enters from the thumb zone; a centered dialog puts its buttons in the middle of the screen.

### Safe areas
```css
.bottom-bar   { padding-bottom: max(16px, env(safe-area-inset-bottom)); }
.sheet-footer { padding-bottom: max(24px, env(safe-area-inset-bottom)); }
.top-bar      { padding-top: env(safe-area-inset-top); }
.full-width   { padding-inline: max(16px, env(safe-area-inset-left)); }
```
Required on every fixed element. The last one matters in landscape on a notched device, where content otherwise slides under the notch.

And **`h-dvh`, never `h-screen`** — `100vh` includes browser chrome that isn't there, so a full-height sheet gets its footer cut off.

---

## Input modality

Three modalities, and a component must work in all three. Detect capability, never device.

```css
@media (hover: hover) and (pointer: fine)   { /* mouse / trackpad */ }
@media (hover: none)  and (pointer: coarse) { /* touch */ }
@media (hover: hover) and (pointer: coarse) { /* TV, some tablets w/ pen */ }
```

| | Mouse / trackpad | Touch | Keyboard |
|---|---|---|---|
| Reveal row actions | hover | **always visible or swipe** | focus |
| Tooltip | hover, 400ms delay | **long-press, or none** | focus |
| Sort | click header | tap header | `Enter` on header |
| Multi-select | `Shift`+click, `Cmd`+click | checkboxes, always visible | `Shift`+arrows |
| Reorder | drag | long-press then drag | `Cmd`+arrows |
| Scrub a chart | hover | drag with a persistent readout | arrows |
| Dismiss | click outside / `Esc` | swipe down / tap outside | `Esc` |

**The hover trap:** touch devices fire `:hover` on tap and it *sticks* until the next tap elsewhere. Every hover effect is gated behind `@media (hover: hover)`. Every hover-revealed affordance has a non-hover path — always visible on touch, or focus-revealed for keyboard.

**Tooltips do not exist on touch.** A long-press tooltip is undiscoverable. If information is only in a tooltip, it's unavailable to touch users — so anything essential goes inline, and tooltips carry only supplementary detail (the unabbreviated value, the full timestamp).

### Trackpad specifics
The laptop primary target means trackpad gestures matter:
- **Two-finger horizontal scroll** must work on a wide table. It does by default with `overflow-x: auto` — but not if you've hijacked `wheel` events. Don't.
- **Overscroll containment** on modals and sheets: `overscroll-behavior: contain`, so scrolling to the end of a sheet doesn't start scrolling the page behind it.
- **Never hijack pinch-zoom.** `user-scalable=no` is forbidden — it's an accessibility failure and it breaks the primary tool low-vision users have for reading a dense table.

### Pointer events over mouse/touch events
`pointerdown`/`pointermove`/`pointerup` with `setPointerCapture()`. One code path for mouse, touch, and pen; capture keeps a drag alive when the pointer leaves the element.

---

## Table collapse {#table-collapse}

The most important responsive decision in the library, because the table is the product.

### Column priority
Every column declares a priority. Columns drop from lowest priority as width shrinks — the design decision is *the ranking*, made once per table, not per breakpoint.

```ts
type ColumnDef = {
  id: string;
  priority: 1 | 2 | 3 | 4;   // 1 = never dropped
  minWidth: number;
  sticky?: "start" | "end";
};
```

Transaction table ranking:
```
P1  date · description · amount        ← never dropped, never truncated (amount)
P2  status · account
P3  category · merchant
P4  reference · memo · created-by · tags
```

### The four presentations

**≥ 1280 — full table.** All columns. Sticky header. Sticky total footer.

**1024–1280 — priority table.** P4 dropped into a per-row expand. Description column flexes and truncates.

**768–1024 — sticky-column table.** Description is `position: sticky; left: 0`, amount is `sticky; right: 0`. The middle scrolls horizontally between them.

This is the pattern that makes a wide financial table genuinely usable at laptop width, and almost nothing implements it: **the label and the number stay pinned; the metadata scrolls between them.** The user never loses their place because the two things they're correlating are always both on screen.

Craft details it needs:
- `background: var(--surface)` on the sticky cells, or scrolled content shows through.
- **Edge fades** — a 24px gradient at each scroll boundary, driven by an `IntersectionObserver` sentinel, telling the user there's more. Never a scroll listener.
- A 1px `--border-subtle` on the inner edge of each sticky column, so it reads as a pinned pane.
- Sticky cells must not clip their focus ring — `outline`, not `box-shadow`.

**< 768 — card list.** Not a table. A genuine re-rank:

```
┌────────────────────────────────────┐
│ ◻  Northwind Ltd          −$1,240.00│   description · amount (P1)
│    3 Sep · Operating ••4291         │   date · account   (P1/P2)
│    ● Pending                        │   status           (P2)
└────────────────────────────────────┘
```

- 72px row height, 16px inset, full-bleed `--border-subtle` divider between rows.
- **Amount right-aligned, `tabular-nums`, never truncated.**
- Logo/avatar at 32px, rounded-square 8.
- Swipe-left reveals actions (categorize, flag) — with an overflow menu as the discoverable equivalent, because swipe is invisible.
- Tap opens a detail sheet with everything, including P3/P4.
- Sticky section headers by date, 32px tall, `--surface-sunken`.
- **Virtualized** past 100 rows.

### Statements on a phone
A P&L has 3–13 period columns. It cannot become a card list — the comparison across periods *is* the content.

- **One period column at a time**, with a horizontally swipeable period selector at the top. The account label column stays fixed.
- The selector is a segmented control that scrolls, with the active period pinned in view.
- Swipe left/right on the statement body moves between periods, with the label column static and the values crossfading (140ms, no translation — translation implies the labels moved too).
- **Indentation halves** on phone (16px per level instead of 24) so a level-4 account name isn't 96px into a 358px content width.
- Rule lines and the double-rule total survive at every width. They're the grammar.

---

## Charts

- **Minimum touch scrub target 44px tall** across the full plot area — a 2px line is not a touch target.
- On touch, the readout is **persistent** (a fixed header showing the scrubbed point), not a floating tooltip that a finger covers.
- **Reduce data density, not chart size.** A 30-point line on a 358px canvas is legible; a 365-point line is a smudge. Downsample on small screens with an LTTB algorithm, and say so.
- Axis labels: abbreviate on phone (`$1.2M`), full on desktop. Rotate never — if labels don't fit, show fewer.
- Legends move below the plot on phone and become a wrapping row.
- **A chart is never the only representation.** Every chart has an accessible table equivalent, reachable by a toggle — which serves screen readers *and* the user who wants the actual numbers.

---

## Forms and the amount input

- **`inputMode="decimal"`** on amount fields → the numeric keypad with a decimal separator. `inputMode="numeric"` gives no decimal point; `type="number"` gives scroll-to-increment, which will silently change someone's payment.
- **16px minimum font-size on inputs.** iOS Safari zooms the viewport on focus for anything smaller, and the zoom doesn't reverse. This overrides our 14px default — inputs are `body-lg`.
- **Bottom-anchored submit** on phone, above the safe area, visible without scrolling.
- **The keyboard covers the bottom 40% of the screen.** Scroll the focused field into view above it, and re-anchor the submit button above the keyboard (`visualViewport` API, not a guess).
- **`autocomplete` on everything** — `cc-number`, `cc-exp`, `cc-csc`, `name`, `email`, `postal-code`. And `one-time-code` on 2FA inputs, so iOS/Android autofill the SMS code.
- **Never block paste.** Restated because it keeps happening in card and IBAN fields.
- Field-level validation on **blur**, not on every keystroke. Validating "not a valid amount" while someone is halfway through typing `12` is hostile.

---

## Motion, per modality

Motion is not decoration to strip on mobile — but it changes shape.

| | Phone | Laptop / desktop |
|---|---|---|
| Primary overlay | Sheet, from the bottom, gesture-dismissible | Dialog, centered, `Esc` |
| Dismissal | Swipe (velocity-aware) | Click-outside / `Esc`, instant |
| Row actions | Swipe-reveal, spring-tracked | Hover-reveal, instant |
| Navigation | Instant. No page transitions. | Instant. |
| Hover effects | None (gated out) | Yes, 140ms |
| Card tilt | **None** — no pointer to track | Yes, spring-tracked |
| Stagger | Yes on first paint | Yes on first paint |
| Value flash | Yes | Yes |

**Phones are where motion earns its keep**, because gestures are the navigation. A sheet that tracks the finger 1:1 and springs back on release is the difference between an app and a website. Spec in [motion catalog](../motion/03-catalog.md#gestures).

**And phones are where motion costs the most.** A mid-range Android has a fraction of the compositor budget of an M-series MacBook. So: compositor properties only, no `backdrop-filter`, no animated blur, no large-surface paint. Test on a real mid-range device throttled to 4× CPU slowdown — not on the laptop that wrote the code.

---

## Density × breakpoint

Density and breakpoint are independent axes. The defaults:

| | Phone | Tablet | Laptop | Desktop |
|---|---|---|---|---|
| Default density | comfortable | default | default | default |
| Available | comfortable only | default, comfortable | all three | all three |
| Row height | 72 (card) | 48 | 40 | 40 |
| Card inset | 16 | 24 | 24 | 24 |
| Body size | 14/20 (16 in inputs) | 14/20 | 14/20 | 14/20 |

`compact` is not offered on phone — 32px rows with 13px text on a touch device fails both the target minimum and legibility at arm's length.

**Density is persisted per user and syncs across devices**, but the phone always resolves to `comfortable` regardless of the stored preference.

---

## Print

Financial documents get printed and PDF'd. Constantly. Ignoring print is ignoring a primary workflow.

```css
@media print {
  :root { color-scheme: light; }        /* never print dark mode */
  .no-print { display: none; }          /* nav, filters, actions, toolbars */
  .card { box-shadow: none; border: 1px solid #ccc; break-inside: avoid; }
  thead { display: table-header-group; } /* repeat headers on every page */
  tr { break-inside: avoid; }
  .statement-total { break-before: avoid; } /* never orphan a total from its rule */
  a[href^="http"]::after { content: " (" attr(href) ")"; }
  @page { margin: 16mm; }
}
```

- **Every statement prints with its header**: entity name, statement type, resolved period, basis (cash/accrual), presentation currency, and generation timestamp. A printed P&L with no period is worthless.
- **Page numbers and "continued"** on multi-page statements.
- **Never break a total from its rule line.** `break-before: avoid` on total rows.
- Charts print — ensure they render in light mode with sufficient contrast in greyscale, since most printers are monochrome. Another reason the greyscale test is a ship gate.

---

## Testing matrix

Every component, before `stable`:

| | |
|---|---|
| **Widths** | 320 · 390 · 768 · 1024 · 1280 · 1920 |
| **Zoom** | 100% · 200% · 400% (= 320px reflow) |
| **Modality** | mouse · touch · keyboard-only · screen reader |
| **Density** | compact · default · comfortable |
| **Theme** | light · dark · forced-colors · signal palette · greyscale |
| **Motion** | normal · reduced |
| **Devices** | real iPhone SE (smallest current) · real mid-range Android · 13" MacBook at 125% scaling |
| **Throttle** | 4× CPU slowdown, Fast 3G |
| **Print** | statement PDF, multi-page |

The real-device rows are not optional. Gesture feel and touch-target adequacy do not survive a simulator, and the 13" MacBook at 125% scaling is where finance teams actually live.
