292 lines
13 KiB
Markdown
292 lines
13 KiB
Markdown
|
|
# 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.
|