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>
13 KiB
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:
src/styles/tokens.css— design tokens (no visual side effects).src/index.css— the original desktop baseline stylesheet.src/styles/responsive.css— media-query overrides.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 matchMediachangeevent viauseSyncExternalStore. 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 fouruseMediaQuerycalls 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}>inPortalLayout. - VisuallyHidden —
sr-onlywrapper, for adding accessible text without visual presence.
Portal chrome
- Topbar (
.portal-topbar): fixed 48 px height at all sizes. Belowmd, 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 (CSSdisplay: none), shown belowlg. 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 atmin(320px, 84vw)wide, over a backdrop atvar(--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-gridbecomes single-column belowlg.
Tables (Transactions, Settlements, Accounts)
Two CSS patterns available:
- Scrolling wrapper (default). Wrap the
<table>in<div class="portal-table-wrapper">. Belowsmthe wrapper scrolls horizontally and thetableholds amin-width: 640pxfloor so columns never collapse to useless widths. - Stacked-card mode (opt-in). Add
portal-table--stackto the<table>and adata-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
mdthe two-column layout (brand left / card right) stacks. Both halves go tomax-width: 100%. min-height: 100vh; min-height: 100dvhaccommodates 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,/accountsso 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 andauto-fitgrids scale continuously; no script-driven layout recalculation on resize.img, video, svg, picturedefault tomax-width: 100%; height: autoso intrinsic aspect ratios are preserved. - No resize listeners.
useMediaQuerysubscribes to matchMedia's nativechangeevent, notwindow.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#60a5fawith 2 px offset. Defined ina11y.css, tightens to 3 px inprefers-contrast: more. - Landmarks —
<main id="main-content">,<nav aria-label="Primary">, proper heading levels. - Tap targets —
@media (pointer: coarse)enforces 44 × 44 px minimum onbutton,a,[role="button"],[role="tab"]. - Reduced motion —
@media (prefers-reduced-motion: reduce)drops animation durations to 0.001 ms; JS-driven animations readuseReducedMotion(). - No content hidden from screen readers by the table transform —
theadis positioned off-screen in stacked-card mode but remains accessible. Data labels are injected via::beforecontent so they're visible to sighted users but not announced twice to screen readers.
Gotchas and non-obvious decisions
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 atmax-width: 100vwso they contain the react-flow canvas correctly.- 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. .portal-topbar-centerhides 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.- Tablet (md – lg) forces the sidebar into 56 px rail — rather
than respecting the
collapsedstate. Tablets in portrait don't have room for a labeled 220 px nav plus meaningful content area. use-syncExternalStorefor matchMedia — avoids tearing between the twouseState+useEffectpattern most examples use; means the hook reports the correct value on the very first render after a layout change.setDrawerOpenfrom effects — thereact-hooks/set-state-in-effectrule 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.csssrc/styles/responsive.csssrc/styles/a11y.csssrc/hooks/useMediaQuery.tssrc/hooks/useBreakpoint.tssrc/hooks/useReducedMotion.tssrc/hooks/useOrientation.tssrc/components/a11y/SkipToContent.tsxsrc/components/a11y/VisuallyHidden.tsxdocs/ux-responsive-strategy.md(this document)
Modified files:
index.html— viewport meta withviewport-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.