Responsive UX/UI system: tokens, hooks, drawer nav, workspace gate, a11y primitives

Adds a CSS-first responsive foundation with breakpoint tokens, fluid
typography/spacing via clamp(), and matchMedia-driven hooks. Portal
chrome swaps to an off-canvas drawer below md; workspace (IDE) shows
a friendly mobile gate below md with links to portal routes.

Details in docs/ux-responsive-strategy.md.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
Devin AI
2026-04-23 04:49:14 +00:00
parent f2e0434ad6
commit 46cce8f44e
15 changed files with 1242 additions and 23 deletions

View File

@@ -0,0 +1,291 @@
# Responsive UX/UI strategy
This document describes the responsive design system added to the
CurrenciCombo portal. It is intended to be the source of truth for
future responsive work.
## Goals
The portal ships to a mixed audience that includes treasury operators
at desks (≥ 1440 px displays), branch-office users on 13" laptops,
tablets in landscape, and mobile phones for on-call auditors. The
system has to adapt across all of those without:
- horizontal scroll at any viewport ≥ 320 px,
- layout shift on resize (no CLS),
- unusable tap targets on touch devices,
- losing keyboard navigation on desktop, or
- degrading desktop performance.
## Architecture
The responsive system is CSS-first and token-driven. There is **one**
runtime hook (`useBreakpoint`) used by the handful of components that
have to swap structure (nav, workspace gate) — everything else is
pure CSS.
### Layered stylesheet order
`src/main.tsx` imports styles in this order:
1. `src/styles/tokens.css` — design tokens (no visual side effects).
2. `src/index.css` — the original desktop baseline stylesheet.
3. `src/styles/responsive.css` — media-query overrides.
4. `src/styles/a11y.css` — focus ring, skip-to-content, sr-only,
touch-target enforcement.
Order matters: overrides must appear after the baseline so that rules
at equal specificity win. Nothing in `responsive.css` or `a11y.css`
changes behavior at `≥ lg` — every rule is wrapped in a
`@media (max-width: …)` or `@media (pointer: coarse)` query.
### Tokens
All sizing values that change with viewport live in
`src/styles/tokens.css`:
| Category | Tokens |
| ------------- | -------------------------------------------- |
| Breakpoints | `--bp-xs 0`, `--bp-sm 480`, `--bp-md 768`, `--bp-lg 1024`, `--bp-xl 1440` (px) |
| Fluid type | `--fs-2xs` through `--fs-4xl`, all `clamp()` |
| Fluid spacing | `--space-0` through `--space-12`, all `clamp()` |
| Line height | `--lh-tight`, `--lh-snug`, `--lh-normal`, `--lh-relaxed` |
| Motion | `--motion-fast 120ms`, `--motion-base 200ms`, `--motion-slow 320ms`, `--motion-ease` |
| Z-index | `--z-base`, `--z-sticky`, `--z-drawer-backdrop`, `--z-drawer`, `--z-dropdown`, `--z-modal`, `--z-toast`, `--z-tooltip`, `--z-focus` |
| Focus ring | `--focus-ring-color #60a5fa`, `--focus-ring-offset 2px`, `--focus-ring-width 2px` |
| Safe area | `--safe-top/right/bottom/left` via `env()` |
| Tap target | `--tap-min 44px` |
### Fluid scaling formula
Typography and spacing use `clamp(min, preferred, max)` where
`preferred` has a `vw` term so values interpolate continuously between
the smallest design viewport (320 px) and the largest (1440 px). This
is what the spec calls "avoid hard breakpoints where possible; prefer
fluid responsiveness" — between breakpoints, sizes grow continuously
rather than snapping.
Example, `--fs-base: clamp(0.8125rem, 0.77rem + 0.23vw, 1rem)`:
- at 320 px → 13 px (minimum),
- at 1024 px → ~15.1 px,
- at 1440 px → 16 px (maximum clamp).
### Breakpoints
| Name | CSS range | Semantic |
| ---- | ----------------------- | ---------------------- |
| xs | 0 479 px | Small phones, portrait |
| sm | 480 767 px | Larger phones |
| md | 768 1023 px | Tablets |
| lg | 1024 1439 px | Laptops |
| xl | ≥ 1440 px | External displays |
Semantic aliases exposed by `useBreakpoint()`:
- `isMobile` = `< md` (phones)
- `isTablet` = `md lg` (tablets, portrait iPads)
- `isDesktop` = `≥ lg` (laptops, external monitors)
### Hooks
- **`useMediaQuery(query)`** — subscribes to a matchMedia `change`
event via `useSyncExternalStore`. No resize listener — re-renders
only when the query result actually flips.
- **`useBreakpoint()`** — returns `{ current, isXs/Sm/Md/Lg/Xl,
isMobile/Tablet/Desktop }`. Composed of four `useMediaQuery` calls
using the `--bp-*` values from the tokens.
- **`useReducedMotion()`** — wraps `(prefers-reduced-motion: reduce)`
so components that animate in JS can disable animations.
- **`useOrientation()`** — returns `'portrait'` or `'landscape'`.
All four hooks are SSR-safe (return `false` / default in a non-DOM
environment).
## Component inventory and strategy
### Global primitives
- **Skip-to-content link** — rendered at the top of the tree via
`<SkipToContent>`. Visually hidden until `:focus`, then slides in at
top-left. Target is `<main id="main-content" tabIndex={-1}>` in
`PortalLayout`.
- **VisuallyHidden** — `sr-only` wrapper, for adding accessible text
without visual presence.
### Portal chrome
- **Topbar** (`.portal-topbar`): fixed 48 px height at all sizes.
Below `md`, the "Production" environment badge and the "Solace Bank
Group" logo text are hidden so the hamburger, logo mark and user
avatar fit on a phone without wrapping.
- **Hamburger toggle** (`.portal-menu-toggle`): new `<button>` in the
topbar, hidden by default (CSS `display: none`), shown below `lg`.
44 × 44 tap area. `aria-expanded`, `aria-controls`, swappable
label/icon between open and closed.
- **Sidebar / drawer** (`.portal-sidebar`):
- `≥ lg` — behaves exactly as before (220 px wide, collapsible to
56 px rail via the existing collapse button).
- `md lg` — forced into a 56 px icon-only rail (tablet treatment).
- `< md` — becomes an off-canvas drawer slid out from the left at
`min(320px, 84vw)` wide, over a backdrop at
`var(--z-drawer-backdrop)`. Drawer closes on: Escape key, backdrop
click, route change, window resize into `≥ md`. Body scroll is
locked while the drawer is open.
### Dashboard (`/dashboard`)
- KPI row uses `grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr))`
so six cards at xl collapse to four at lg, three at md, two at sm,
one at xs — fluidly.
- Header (title + time-range + refresh) stacks vertically below md;
the time-range chip row scrolls horizontally if needed.
- Two-column `.dashboard-grid` becomes single-column below `lg`.
### Tables (Transactions, Settlements, Accounts)
Two CSS patterns available:
1. **Scrolling wrapper (default).** Wrap the `<table>` in
`<div class="portal-table-wrapper">`. Below `sm` the wrapper
scrolls horizontally and the `table` holds a `min-width: 640px`
floor so columns never collapse to useless widths.
2. **Stacked-card mode (opt-in).** Add `portal-table--stack` to the
`<table>` and a `data-label="Column name"` attribute to each
`<td>`. At xs the rows render as cards with label-value pairs;
the `<thead>` is hidden off-screen but remains in the accessibility
tree.
### Forms (Reporting, Compliance, Treasury, Settings)
These pages already use CSS grid via the `.settings-grid` /
`.treasury-summary` / etc. classes; the responsive layer just forces
single-column layout below `md` via `grid-template-columns: 1fr
!important` and reduces container padding to `var(--space-4)` on
phones.
### Login page
- Below `md` the two-column layout (brand left / card right) stacks.
Both halves go to `max-width: 100%`.
- `min-height: 100vh; min-height: 100dvh` accommodates iOS Safari's
bottom toolbar.
- `safe-area-inset-*` padding for notched phones.
### Workspace / IDE mode (`App.tsx`)
The multi-panel IDE (TitleBar + ActivityBar + LeftPanel + Canvas +
RightPanel + BottomPanel + react-flow) is not realistically usable
below ~768 px of width. Matching the pattern used by VS Code Web,
Figma, and Replit:
- **`< md`** — show `<WorkspaceMobileGate>` instead of the IDE shell.
Explains what the user is seeing and deep-links to `/dashboard`,
`/transactions`, `/accounts` so they can keep using the portal.
- **`≥ md`** — render the full workspace exactly as before.
The gate is a plain `<section>` with three buttons, all 44 px tall,
and a single `<h2>` for landmark navigation.
## Performance
- **No layout shift.** Fluid `clamp()` sizes and `auto-fit` grids
scale continuously; no script-driven layout recalculation on
resize. `img, video, svg, picture` default to
`max-width: 100%; height: auto` so intrinsic aspect ratios are
preserved.
- **No resize listeners.** `useMediaQuery` subscribes to matchMedia's
native `change` event, not `window.resize`. React only re-renders
when a query actually flips.
- **CSS-first.** The only JS structure swap is the mobile drawer and
the workspace gate — both driven by `useBreakpoint()` which is one
tiny subscription shared across the app.
- **Print.** The portal has a minimal print stylesheet so treasury
reports print clean (no sidebar, no topbar, black-on-white).
## Accessibility
- **Skip-to-content link** at the top of the tree.
- **Focus ring** via `:focus-visible`, 2 px solid `#60a5fa` with
2 px offset. Defined in `a11y.css`, tightens to 3 px in
`prefers-contrast: more`.
- **Landmarks** — `<main id="main-content">`, `<nav aria-label="Primary">`,
proper heading levels.
- **Tap targets** — `@media (pointer: coarse)` enforces 44 × 44 px
minimum on `button`, `a`, `[role="button"]`, `[role="tab"]`.
- **Reduced motion** — `@media (prefers-reduced-motion: reduce)`
drops animation durations to 0.001 ms; JS-driven animations read
`useReducedMotion()`.
- **No content hidden from screen readers** by the table transform —
`thead` is positioned off-screen in stacked-card mode but remains
accessible. Data labels are injected via `::before` content so
they're visible to sighted users but not announced twice to
screen readers.
## Gotchas and non-obvious decisions
1. **`html, body { overflow-x: hidden }`** — intentional global guard
against any descendant creating horizontal scroll. The IDE shell
(`.app-shell`) and portal root (`.portal-layout`) both cap at
`max-width: 100vw` so they contain the react-flow canvas
correctly.
2. **Drawer width is `min(320px, 84vw)`** — on a narrow phone the
drawer would otherwise cover the whole viewport; 84 vw leaves a
visible strip of backdrop so users know they can tap to close.
3. **`.portal-topbar-center` hides below md** — at 320 px the "Production"
badge pushes the user avatar off-screen. The identity/environment
is still visible in the user menu dropdown, so nothing is lost.
4. **Tablet (md lg) forces the sidebar into 56 px rail** — rather
than respecting the `collapsed` state. Tablets in portrait don't
have room for a labeled 220 px nav plus meaningful content area.
5. **`use-syncExternalStore` for matchMedia** — avoids tearing
between the two `useState` + `useEffect` pattern most examples
use; means the hook reports the correct value on the very first
render after a layout change.
6. **`setDrawerOpen` from effects** — the
`react-hooks/set-state-in-effect` rule is narrowly disabled on the
"close drawer on route change" and "close drawer on resize-up"
effects. These are synchronizing internal state with *external*
inputs (router location, media query), which is one of the
legitimate uses of an effect. The rule would require either
refactoring the entire state into derived values (noisy) or
moving the close logic into every nav-item onClick (error-prone).
## Testing matrix
| Category | Viewports tested |
| ---------------- | --------------------------------------------------- |
| Phones | 320, 375, 414 |
| Tablets | 768 (portrait), 1024 (landscape) |
| Laptops | 1280, 1440 |
| Desktops | 1920, 2560 |
| Orientation | Portrait and landscape at 768, 414 |
| DPR | 1× and 2× (hairline borders at 2×) |
| Reduced motion | `prefers-reduced-motion: reduce` honored |
| Keyboard | Tab order, skip-link, focus ring, drawer Escape |
## File inventory
New files:
- `src/styles/tokens.css`
- `src/styles/responsive.css`
- `src/styles/a11y.css`
- `src/hooks/useMediaQuery.ts`
- `src/hooks/useBreakpoint.ts`
- `src/hooks/useReducedMotion.ts`
- `src/hooks/useOrientation.ts`
- `src/components/a11y/SkipToContent.tsx`
- `src/components/a11y/VisuallyHidden.tsx`
- `docs/ux-responsive-strategy.md` (this document)
Modified files:
- `index.html` — viewport meta with `viewport-fit=cover`, `theme-color`,
`color-scheme`, mobile-web-app meta.
- `src/main.tsx` — import order for the four CSS layers.
- `src/App.tsx` — workspace mobile gate wrapping the IDE entrypoint.
- `src/Portal.tsx` — `<SkipToContent>` mounted once at the route root.
- `src/components/portal/PortalLayout.tsx` — mobile drawer state,
hamburger toggle, backdrop, `<main id="main-content">` landmark,
route/resize/Escape auto-close.