Files
CurrenciCombo/docs/ux-responsive-strategy.md
Devin AI 46cce8f44e 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>
2026-04-23 04:49:14 +00:00

13 KiB
Raw Blame History

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