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:
291
docs/ux-responsive-strategy.md
Normal file
291
docs/ux-responsive-strategy.md
Normal 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.
|
||||
Reference in New Issue
Block a user