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>
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.
|