Responsive UX/UI: tokens, hooks, drawer nav, workspace gate, a11y primitives #33

Open
nsatoshi wants to merge 1 commits from devin/1776919187-responsive-uiux-system into main
15 changed files with 1242 additions and 23 deletions

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

View File

@@ -3,7 +3,18 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0f1419" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<meta name="description" content="Solace Bank Group PLC — Treasury management, settlement orchestration, and ISO-20022 transaction builder." />
<title>Solace Bank Group PLC — Treasury Management Portal</title>
</head>
<body>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
import TitleBar from './components/TitleBar';
import ActivityBar from './components/ActivityBar';
@@ -7,10 +8,45 @@ import Canvas from './components/Canvas';
import RightPanel from './components/RightPanel';
import BottomPanel from './components/BottomPanel';
import CommandPalette from './components/CommandPalette';
import { useBreakpoint } from './hooks/useBreakpoint';
import type { ActivityTab, SessionMode, ComponentItem, HistoryEntry, TransactionTab, TerminalEntry, AuditEntry, ValidationIssue } from './types';
const STORAGE_KEY = 'transactflow-workspace';
/* The workspace (IDE-style multi-panel + react-flow canvas) is designed
* for ≥ md viewports. Phones get a friendly "open on a larger screen"
* screen with direct links into the portal routes. This matches the
* pattern used by VS Code Web, Figma, Replit, etc.
*/
function WorkspaceMobileGate() {
return (
<section className="workspace-mobile-gate" aria-labelledby="wsgate-h">
<h2 id="wsgate-h">Transaction Builder is designed for larger screens</h2>
<p>
The Transaction Builder workspace uses a multi-panel IDE layout that
needs more room than a phone can comfortably provide. Please open this
workspace on a tablet in landscape or on a laptop / desktop.
</p>
<p>You can still use the rest of the portal on this device:</p>
<div className="cta-row">
<Link to="/dashboard" className="cta primary">Go to Overview</Link>
<Link to="/transactions" className="cta">View Transactions</Link>
<Link to="/accounts" className="cta">Accounts</Link>
</div>
</section>
);
}
export default function AppWithMobileGate() {
const { isMobile } = useBreakpoint();
return (
<>
{isMobile && <WorkspaceMobileGate />}
{!isMobile && <WorkspaceApp />}
</>
);
}
function loadWorkspace() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -25,7 +61,7 @@ function saveWorkspace(state: Record<string, unknown>) {
} catch { /* ignore */ }
}
export default function App() {
function WorkspaceApp() {
const saved = useRef(loadWorkspace());
const [activityTab, setActivityTab] = useState<ActivityTab>(saved.current?.activityTab || 'builder');

View File

@@ -10,6 +10,7 @@ import SettlementsPage from './pages/SettlementsPage';
import TransactionsPage from './pages/TransactionsPage';
import PortalLayout from './components/portal/PortalLayout';
import LiveChainBanner from './components/portal/LiveChainBanner';
import SkipToContent from './components/a11y/SkipToContent';
import App from './App';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -44,7 +45,9 @@ export default function Portal() {
}
return (
<Routes>
<>
<SkipToContent targetId="main-content" />
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
@@ -166,7 +169,8 @@ export default function Portal() {
/>
<Route path="*" element={<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />} />
</Routes>
</Routes>
</>
);
}

View File

@@ -0,0 +1,16 @@
/**
* SkipToContent — first focusable element in the app.
* Keyboard users press Tab on page load; this link appears and, on
* Enter, jumps focus past the nav to the <main id="main-content">.
*
* Render once, near the top of the tree. The target element must
* exist with id="main-content" and tabIndex={-1} so focus can land
* on it programmatically.
*/
export default function SkipToContent({ targetId = 'main-content' }: { targetId?: string }) {
return (
<a className="skip-to-content" href={`#${targetId}`}>
Skip to main content
</a>
);
}

View File

@@ -0,0 +1,11 @@
import type { PropsWithChildren } from 'react';
/**
* VisuallyHidden — renders content that is present in the DOM (and
* announced by screen readers) but visually hidden. Prefer this over
* aria-label when the label would benefit from being inspectable in
* devtools.
*/
export default function VisuallyHidden({ children }: PropsWithChildren) {
return <span className="sr-only">{children}</span>;
}

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useBreakpoint } from '../../hooks/useBreakpoint';
import {
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
ExternalLink, ChevronDown, GitBranch
ExternalLink, ChevronDown, GitBranch, Menu, X,
} from 'lucide-react';
const navItems = [
@@ -26,12 +27,53 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
const { user, wallet, disconnect } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const { isMobile } = useBreakpoint();
const [collapsed, setCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const currentPath = location.pathname;
/* Sync drawer closed state with route and breakpoint changes.
These setState-in-effect calls are intentional: the drawer must
close in response to external inputs (router location, viewport
media query) — this is exactly what an effect is for. */
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrawerOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!isMobile) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrawerOpen(false);
}
}, [isMobile]);
/* Close drawer + menus on Escape (external subscription, allowed in effects) */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setDrawerOpen(false);
setShowUserMenu(false);
setShowNotifications(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
/* Lock body scroll while drawer is open on mobile */
useEffect(() => {
if (drawerOpen) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}
}, [drawerOpen]);
const copyAddress = () => {
if (wallet?.address) {
navigator.clipboard.writeText(wallet.address);
@@ -42,7 +84,23 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
<div className="portal-layout">
<div className="portal-topbar">
<div className="portal-topbar-left">
<div className="portal-logo" onClick={() => navigate('/dashboard')}>
{/* Mobile hamburger — hidden ≥ lg via CSS */}
<button
className="portal-menu-toggle"
aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
aria-expanded={drawerOpen}
aria-controls="portal-primary-nav"
onClick={() => setDrawerOpen(v => !v)}
>
{drawerOpen ? <X size={20} /> : <Menu size={20} />}
</button>
<div
className="portal-logo"
role="link"
tabIndex={0}
onClick={() => navigate('/dashboard')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/dashboard'); }}
>
<Building2 size={22} color="#3b82f6" />
{!collapsed && (
<div className="portal-logo-text">
@@ -62,28 +120,34 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
<div className="portal-topbar-right">
<div className="portal-notif-wrapper">
<button className="portal-icon-btn" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
<button
className="portal-icon-btn"
aria-label="Notifications"
aria-haspopup="true"
aria-expanded={showNotifications}
onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}
>
<Bell size={18} />
<span className="portal-notif-badge">3</span>
<span className="portal-notif-badge" aria-label="3 unread">3</span>
</button>
{showNotifications && (
<div className="portal-dropdown notifications-dropdown">
<div className="portal-dropdown notifications-dropdown" role="menu">
<div className="portal-dropdown-header">Notifications</div>
<div className="portal-dropdown-item warning">
<div className="portal-dropdown-item warning" role="menuitem">
<span className="dropdown-dot warning" />
<div>
<div className="dropdown-title">AML Alert</div>
<div className="dropdown-desc">Unusual pattern on ACC-001</div>
</div>
</div>
<div className="portal-dropdown-item info">
<div className="portal-dropdown-item info" role="menuitem">
<span className="dropdown-dot info" />
<div>
<div className="dropdown-title">Settlement Confirmed</div>
<div className="dropdown-desc">TX-2024-0847 settled</div>
</div>
</div>
<div className="portal-dropdown-item">
<div className="portal-dropdown-item" role="menuitem">
<span className="dropdown-dot success" />
<div>
<div className="dropdown-title">Report Ready</div>
@@ -95,7 +159,13 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
<div className="portal-user-wrapper">
<button className="portal-user-btn" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
<button
className="portal-user-btn"
aria-label="Account menu"
aria-haspopup="true"
aria-expanded={showUserMenu}
onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}
>
<div className="portal-avatar">
<User size={14} />
</div>
@@ -106,12 +176,12 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
<ChevronDown size={12} />
</button>
{showUserMenu && (
<div className="portal-dropdown user-dropdown">
<div className="portal-dropdown user-dropdown" role="menu">
<div className="portal-dropdown-header">Account</div>
<div className="portal-dropdown-section">
<div className="portal-wallet-addr">
<span className="mono">{wallet?.address ? `${wallet.address.slice(0, 8)}...${wallet.address.slice(-6)}` : '—'}</span>
<button className="copy-btn" onClick={copyAddress} title="Copy address"><Copy size={12} /></button>
<button className="copy-btn" onClick={copyAddress} title="Copy address" aria-label="Copy wallet address"><Copy size={12} /></button>
</div>
<div className="portal-wallet-bal">
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
@@ -119,14 +189,14 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
</div>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action" onClick={() => navigate('/settings')}>
<button className="portal-dropdown-action" role="menuitem" onClick={() => navigate('/settings')}>
<Settings size={14} /> Settings
</button>
<button className="portal-dropdown-action" onClick={() => window.open('https://etherscan.io', '_blank')}>
<button className="portal-dropdown-action" role="menuitem" onClick={() => window.open('https://etherscan.io', '_blank')}>
<ExternalLink size={14} /> View on Explorer
</button>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action danger" onClick={disconnect}>
<button className="portal-dropdown-action danger" role="menuitem" onClick={disconnect}>
<LogOut size={14} /> Disconnect Wallet
</button>
</div>
@@ -136,7 +206,17 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
<div className="portal-body">
<nav className={`portal-sidebar ${collapsed ? 'collapsed' : ''}`}>
{/* Drawer backdrop — visible only on < md and only when drawer is open */}
<div
className={`portal-drawer-backdrop ${drawerOpen ? 'visible' : ''}`}
onClick={() => setDrawerOpen(false)}
aria-hidden="true"
/>
<nav
id="portal-primary-nav"
aria-label="Primary"
className={`portal-sidebar ${collapsed ? 'collapsed' : ''} ${drawerOpen ? 'drawer-open' : ''}`}
>
<div className="portal-nav-items">
{navItems.map(item => {
const Icon = item.icon;
@@ -147,6 +227,7 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
className={`portal-nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}
title={collapsed ? item.label : undefined}
aria-current={isActive ? 'page' : undefined}
>
<Icon size={18} />
{!collapsed && <span>{item.label}</span>}
@@ -157,17 +238,25 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
<div className="portal-nav-footer">
<button className="portal-nav-item" onClick={() => navigate('/settings')} title={collapsed ? 'Settings' : undefined}>
<button
className="portal-nav-item"
onClick={() => navigate('/settings')}
title={collapsed ? 'Settings' : undefined}
>
<Settings size={18} />
{!collapsed && <span>Settings</span>}
</button>
<button className="portal-collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<button
className="portal-collapse-btn"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</nav>
<main className="portal-content">
<main id="main-content" tabIndex={-1} className="portal-content">
{children}
</main>
</div>

View File

@@ -0,0 +1,55 @@
import { useMediaQuery } from './useMediaQuery';
/**
* Breakpoint constants. Kept in sync with the CSS tokens declared
* in src/styles/tokens.css (--bp-*). If you change one, change both.
*/
export const BREAKPOINTS = {
xs: 0,
sm: 480,
md: 768,
lg: 1024,
xl: 1440,
} as const;
export type BreakpointName = keyof typeof BREAKPOINTS;
/**
* useBreakpoint — returns the current active breakpoint name plus
* a set of convenience booleans. Uses matchMedia internally; does
* not register a window-resize listener.
*/
export function useBreakpoint(): {
current: BreakpointName;
isXs: boolean;
isSm: boolean;
isMd: boolean;
isLg: boolean;
isXl: boolean;
isMobile: boolean; // < md
isTablet: boolean; // md and < lg
isDesktop: boolean; // >= lg
} {
const isSm = useMediaQuery(`(min-width: ${BREAKPOINTS.sm}px)`);
const isMd = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
const isLg = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
const isXl = useMediaQuery(`(min-width: ${BREAKPOINTS.xl}px)`);
let current: BreakpointName = 'xs';
if (isXl) current = 'xl';
else if (isLg) current = 'lg';
else if (isMd) current = 'md';
else if (isSm) current = 'sm';
return {
current,
isXs: current === 'xs',
isSm: current === 'sm',
isMd: current === 'md',
isLg: current === 'lg',
isXl: current === 'xl',
isMobile: !isMd,
isTablet: isMd && !isLg,
isDesktop: isLg,
};
}

View File

@@ -0,0 +1,37 @@
import { useSyncExternalStore } from 'react';
/**
* useMediaQuery — subscribe to a CSS media query with zero re-render
* churn on unrelated resize events (uses matchMedia's `change` event
* rather than the window resize event).
*
* Safe for SSR: returns `false` on the server when matchMedia is
* undefined. The Vite SPA here is CSR-only but the hook stays SSR-safe
* for reuse.
*/
export function useMediaQuery(query: string): boolean {
const subscribe = (callback: () => void): (() => void) => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return () => {};
}
const mql = window.matchMedia(query);
// Safari <14 used addListener/removeListener — modern Safari,
// Chrome, Firefox, Edge all support add/removeEventListener.
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
// Fallback path for very old engines.
mql.addListener(callback);
return () => mql.removeListener(callback);
};
const getSnapshot = (): boolean => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(query).matches;
};
const getServerSnapshot = (): boolean => false;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

View File

@@ -0,0 +1,10 @@
import { useMediaQuery } from './useMediaQuery';
/**
* useOrientation — returns 'portrait' or 'landscape' using CSS media
* query rather than the deprecated window.orientation.
*/
export function useOrientation(): 'portrait' | 'landscape' {
const isPortrait = useMediaQuery('(orientation: portrait)');
return isPortrait ? 'portrait' : 'landscape';
}

View File

@@ -0,0 +1,12 @@
import { useMediaQuery } from './useMediaQuery';
/**
* useReducedMotion — true when the user has requested reduced motion
* at the OS level. CSS already honors this globally via
* @media (prefers-reduced-motion: reduce) in tokens.css; use the hook
* only when a component needs to alter JS animation logic (eg react-flow
* auto-fit animations, chart transitions).
*/
export function useReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}

View File

@@ -1,7 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
// Load tokens first (no visual changes on their own), then existing
// baseline stylesheet, then responsive overrides, then a11y primitives.
// Order matters: overrides must win at the same specificity.
import './styles/tokens.css'
import './index.css'
import './styles/responsive.css'
import './styles/a11y.css'
import Portal from './Portal'
import { AuthProvider } from './contexts/AuthContext'

71
src/styles/a11y.css Normal file
View File

@@ -0,0 +1,71 @@
/* ═══════════════════════════════════════════════════════════════
Accessibility primitives (loaded after tokens.css)
Goals:
- Keyboard-visible focus on every interactive element
- Skip-to-content link for screen reader and keyboard users
- Visually-hidden utility (sr-only) preserved across all sizes
- Minimum 44×44px tap targets on touch pointer
═══════════════════════════════════════════════════════════════ */
/* Focus ring — applied to every interactive element that receives
keyboard focus (:focus-visible = only keyboard / programmatic focus).
Mouse-click focus still renders the app's existing hover states. */
:where(button, a, [role="button"], [role="tab"], [role="menuitem"],
input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
outline: var(--focus-ring-width, 2px) solid var(--focus-ring-color, #60a5fa);
outline-offset: var(--focus-ring-offset, 2px);
border-radius: 3px;
}
/* Skip-to-content link: visually hidden until keyboard-focused,
then fixed top-left. */
.skip-to-content {
position: fixed;
top: calc(var(--safe-top) + 8px);
left: calc(var(--safe-left) + 8px);
z-index: var(--z-focus);
background: var(--accent-blue, #3b82f6);
color: #fff;
padding: 10px 16px;
border-radius: var(--radius-md, 6px);
font-weight: 600;
font-size: var(--fs-sm, 0.875rem);
text-decoration: none;
transform: translateY(-200%);
transition: transform var(--motion-base, 200ms) var(--motion-ease);
}
.skip-to-content:focus,
.skip-to-content:focus-visible {
transform: translateY(0);
outline: 3px solid #fff;
outline-offset: 2px;
}
/* Visually-hidden utility — in the DOM, announced by screen readers,
not visible on screen. Standard sr-only pattern. */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* When the cursor is not fine (mobile / tablet / stylus touch),
ensure every interactive element is at least 44×44 CSS px.
:not([data-tap-ok]) escape hatch for elements that intentionally
opt out (eg tight inline chevrons whose parent is the real target). */
@media (pointer: coarse) {
button:not([data-tap-ok]),
[role="button"]:not([data-tap-ok]),
a:not([data-tap-ok]),
[role="tab"]:not([data-tap-ok]) {
min-height: var(--tap-min, 44px);
min-width: var(--tap-min, 44px);
}
}

438
src/styles/responsive.css Normal file
View File

@@ -0,0 +1,438 @@
/* ═══════════════════════════════════════════════════════════════
Responsive overrides — loaded AFTER index.css so these rules win
where specificity ties, without hand-editing the 3.9k-line
baseline stylesheet.
Convention: only rules that change at a breakpoint live here. The
desktop (≥ lg) view is preserved identically to the pre-existing
layout — every override is wrapped in @media (max-width: ...) or
@media (pointer: coarse) so nothing changes above lg.
Mobile-first principle: the _default_ declarations here assume a
small viewport; @media (min-width) queries opt back into the
desktop baseline where index.css already provides it.
═══════════════════════════════════════════════════════════════ */
/* ─────────────────────────────────────────────────────────────
0. Global guards (apply at every size)
───────────────────────────────────────────────────────────── */
/* No horizontal scrolling at any viewport. Anything that actually
needs horizontal scroll should set its own overflow-x on a
scoped element (eg portal-table-wrapper). */
html, body {
max-width: 100vw;
overflow-x: hidden;
}
/* Images / media default to fluid. */
img, video, svg, picture {
max-width: 100%;
height: auto;
display: block;
}
img[width][height] {
height: auto; /* preserve aspect via intrinsic size; prevents CLS */
}
/* Allow the IDE shell to escape the overflow:hidden on html so
react-flow's canvas still composes correctly. */
.app-shell, .portal-layout {
max-width: 100vw;
}
/* ─────────────────────────────────────────────────────────────
1. Portal topbar — stacks to compact, still single-row
───────────────────────────────────────────────────────────── */
@media (max-width: 767.98px) {
.portal-topbar {
padding: 0 12px;
padding-left: calc(12px + var(--safe-left));
padding-right: calc(12px + var(--safe-right));
gap: 8px;
}
/* Hide the "Production" env badge below md — the critical
identity info (logo, user avatar) stay visible. */
.portal-topbar-center { display: none; }
/* Trim logo text below sm so the hamburger + user avatar fit. */
.portal-logo-text { display: none; }
}
/* Mobile drawer toggle — always visible below md, invisible at md+ */
.portal-menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: var(--tap-min);
height: var(--tap-min);
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md, 6px);
color: var(--text-primary);
cursor: pointer;
}
.portal-menu-toggle:hover { background: var(--bg-hover); }
@media (max-width: 1023.98px) {
.portal-menu-toggle { display: inline-flex; }
}
/* ─────────────────────────────────────────────────────────────
2. Portal sidebar — becomes off-canvas drawer < md,
remains a rail 56-220px md→lg, collapses via existing
button at lg+
───────────────────────────────────────────────────────────── */
@media (max-width: 767.98px) {
.portal-sidebar {
position: fixed;
top: 48px; /* under topbar */
left: 0;
bottom: 0;
width: min(320px, 84vw);
transform: translateX(-100%);
transition: transform var(--motion-base) var(--motion-ease);
z-index: var(--z-drawer);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding-left: var(--safe-left);
}
.portal-sidebar.drawer-open {
transform: translateX(0);
}
/* Force the drawer to show labels — override .collapsed when the
user happens to have a collapsed preference on desktop before
rotating to portrait. */
.portal-sidebar.drawer-open .portal-nav-item span,
.portal-sidebar.drawer-open .portal-logo-text {
display: inline;
}
/* Collapse-button (for desktop rail) is useless on mobile. */
.portal-sidebar .portal-collapse-btn { display: none; }
}
/* Drawer backdrop — rendered only when drawer is open on mobile. */
.portal-drawer-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: var(--z-drawer-backdrop);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
@media (max-width: 767.98px) {
.portal-drawer-backdrop.visible { display: block; }
}
@media (min-width: 768px) and (max-width: 1023.98px) {
/* Tablet: force collapsed rail so the content has breathing room. */
.portal-sidebar {
width: 56px;
}
.portal-sidebar .portal-nav-item span { display: none; }
.portal-sidebar .portal-nav-item {
justify-content: center;
padding: 12px 0;
}
.portal-sidebar .portal-collapse-btn { display: none; }
}
/* ─────────────────────────────────────────────────────────────
3. Portal content — safe-area padding on notched devices
───────────────────────────────────────────────────────────── */
.portal-content {
padding-bottom: var(--safe-bottom);
}
@media (max-width: 767.98px) {
.portal-content {
padding-left: var(--safe-left);
padding-right: var(--safe-right);
}
}
/* ─────────────────────────────────────────────────────────────
4. Dashboard — KPI grid auto-fit, header stacks < md
───────────────────────────────────────────────────────────── */
.kpi-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
gap: var(--space-4);
}
@media (max-width: 767.98px) {
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.dashboard-header-right {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
.time-range-selector {
flex: 1 1 auto;
overflow-x: auto;
scrollbar-width: thin;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.dashboard-page {
padding: var(--space-5);
}
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* Dashboard and page containers use fluid type at all sizes. */
.dashboard-header-left h1 {
font-size: var(--fs-2xl);
line-height: var(--lh-tight);
}
.kpi-value {
font-size: var(--fs-xl);
line-height: var(--lh-tight);
}
.kpi-label {
font-size: var(--fs-2xs);
}
/* ─────────────────────────────────────────────────────────────
5. Portal tables — horizontal-scroll wrapper at sm,
stacked-card mode at xs
───────────────────────────────────────────────────────────── */
/* Scroll wrapper: every table should be wrapped in
<div class="portal-table-wrapper"> so it scrolls horizontally
instead of breaking the page. */
.portal-table-wrapper {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
border-radius: var(--radius-md, 6px);
}
.portal-table-wrapper table {
min-width: 640px; /* forces horizontal scroll below sm */
}
@media (max-width: 479.98px) {
/* Stacked-card mode: table rows become "cards" stacked vertically,
each cell labeled by its data-label attribute. Activate by adding
class="portal-table portal-table--stack" on the <table>. */
.portal-table--stack,
.portal-table--stack thead,
.portal-table--stack tbody,
.portal-table--stack tr,
.portal-table--stack th,
.portal-table--stack td {
display: block;
}
.portal-table--stack thead {
position: absolute;
left: -9999px;
top: -9999px;
}
.portal-table--stack tr {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md, 6px);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.portal-table--stack td {
border: none;
padding: var(--space-2) 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.portal-table--stack td::before {
content: attr(data-label);
font-size: var(--fs-2xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
font-weight: 600;
flex: 0 0 auto;
}
/* Stack-mode tables should stop forcing min-width */
.portal-table--stack {
min-width: 0 !important;
}
}
/* ─────────────────────────────────────────────────────────────
6. Login — stack columns vertically below md; safe area
───────────────────────────────────────────────────────────── */
@media (max-width: 767.98px) {
.login-container {
flex-direction: column;
gap: var(--space-6);
padding: var(--space-5);
align-items: stretch;
}
.login-left, .login-right {
max-width: 100%;
width: 100%;
}
.login-features { gap: var(--space-4); }
.login-compliance-badges { flex-wrap: wrap; }
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.login-container {
gap: 32px;
padding: var(--space-6);
}
}
.login-page {
padding-top: calc(var(--safe-top) + var(--space-5));
padding-bottom: calc(var(--safe-bottom) + var(--space-5));
padding-left: calc(var(--safe-left) + var(--space-4));
padding-right: calc(var(--safe-right) + var(--space-4));
min-height: 100vh;
min-height: 100dvh; /* avoid iOS Safari bottom-bar viewport glitch */
}
/* ─────────────────────────────────────────────────────────────
7. Page containers — add fluid padding and section headings
───────────────────────────────────────────────────────────── */
.accounts-page, .treasury-page, .reporting-page,
.compliance-page, .settlements-page, .settings-page {
padding: var(--space-6);
}
@media (max-width: 767.98px) {
.accounts-page, .treasury-page, .reporting-page,
.compliance-page, .settlements-page, .settings-page {
padding: var(--space-4);
}
/* Summary rows on account/settlements/treasury pages stack. */
.accounts-summary,
.settlements-summary,
.treasury-summary {
grid-template-columns: 1fr !important;
}
}
/* ─────────────────────────────────────────────────────────────
8. Workspace (IDE) — gate below md with a friendly message.
Above md, original layout is preserved; on coarse pointers
we still let the user drive it but the gate lets small
phones bail cleanly to the portal.
───────────────────────────────────────────────────────────── */
.workspace-mobile-gate {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--space-8) var(--space-5);
text-align: center;
background: var(--bg-base);
color: var(--text-primary);
gap: var(--space-5);
}
.workspace-mobile-gate h2 {
font-size: var(--fs-2xl);
line-height: var(--lh-snug);
max-width: 28ch;
}
.workspace-mobile-gate p {
color: var(--text-secondary);
font-size: var(--fs-base);
line-height: var(--lh-relaxed);
max-width: 42ch;
}
.workspace-mobile-gate .cta-row {
display: flex;
flex-direction: column;
gap: var(--space-3);
width: 100%;
max-width: 320px;
}
.workspace-mobile-gate button,
.workspace-mobile-gate a.cta {
min-height: var(--tap-min);
padding: 0 var(--space-6);
border-radius: var(--radius-md, 6px);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-size: var(--fs-base);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
text-decoration: none;
}
.workspace-mobile-gate .cta.primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.workspace-mobile-gate .cta.primary:hover { filter: brightness(1.08); }
@media (max-width: 767.98px) {
.workspace-mobile-gate { display: flex; }
.workspace-mobile-gate + .app-shell { display: none; }
}
/* ─────────────────────────────────────────────────────────────
9. Command palette — fit narrow viewports
───────────────────────────────────────────────────────────── */
@media (max-width: 767.98px) {
.command-palette-overlay { padding-top: max(40px, var(--safe-top)); }
.command-palette {
width: min(94vw, 520px);
max-height: 70vh;
}
}
/* ─────────────────────────────────────────────────────────────
10. Scrollable panels — portrait-orientation tablet tweaks
───────────────────────────────────────────────────────────── */
@media (orientation: portrait) and (min-width: 768px) and (max-width: 1023.98px) {
/* Tablet portrait: give the portal-content more height by using
dynamic viewport units. */
.portal-body { min-height: calc(100dvh - 48px); }
}
/* ─────────────────────────────────────────────────────────────
11. Print styles (bonus — critical for financial portals)
───────────────────────────────────────────────────────────── */
@media print {
.portal-topbar, .portal-sidebar, .portal-menu-toggle,
.portal-drawer-backdrop, .app-shell .title-bar,
.app-shell .activity-bar, .app-shell .bottom-panel,
.app-shell .right-panel { display: none !important; }
.portal-content, .app-body, .portal-body {
display: block !important;
overflow: visible !important;
}
body { background: #fff !important; color: #000 !important; }
a { color: inherit !important; text-decoration: underline; }
}

132
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,132 @@
/* ═══════════════════════════════════════════════════════════════
Design tokens — responsive foundation
Loaded BEFORE index.css so existing rules can consume these
variables. Strictly additive: no variable declared here conflicts
with an existing value in index.css.
Breakpoint policy (mobile-first, min-width):
xs: 0 479px (narrow phones, portrait)
sm: 480 767px (large phones, small tablets portrait)
md: 768 1023px (tablets landscape, small laptops)
lg: 1024 1439px (desktops, larger laptops)
xl: 1440px+ (wide desktops, ultra-wide)
We prefer container/fluid behavior; breakpoints gate only layout
switches that cannot be expressed fluidly (nav drawer, workspace
availability, table→card transform).
═══════════════════════════════════════════════════════════════ */
:root {
/* Breakpoints (CSS custom props mirror the JS hook constants) */
--bp-xs: 0px;
--bp-sm: 480px;
--bp-md: 768px;
--bp-lg: 1024px;
--bp-xl: 1440px;
/* Fluid type scale — clamp(min, preferred, max)
Preferred uses a viewport-width linear function so text grows
smoothly between xs and xl without hard breakpoint jumps.
All values are in rem so user font-size preferences are honored. */
--fs-2xs: clamp(0.625rem, 0.59rem + 0.17vw, 0.75rem); /* 10→12px */
--fs-xs: clamp(0.6875rem, 0.65rem + 0.19vw, 0.8125rem); /* 11→13px */
--fs-sm: clamp(0.75rem, 0.71rem + 0.21vw, 0.875rem); /* 12→14px */
--fs-base: clamp(0.8125rem, 0.77rem + 0.23vw, 1rem); /* 13→16px */
--fs-md: clamp(0.875rem, 0.83rem + 0.25vw, 1.125rem); /* 14→18px */
--fs-lg: clamp(1rem, 0.93rem + 0.38vw, 1.25rem); /* 16→20px */
--fs-xl: clamp(1.125rem, 1.0rem + 0.63vw, 1.5rem); /* 18→24px */
--fs-2xl: clamp(1.25rem, 1.04rem + 1.04vw, 1.875rem); /* 20→30px */
--fs-3xl: clamp(1.5rem, 1.19rem + 1.56vw, 2.25rem); /* 24→36px */
--fs-4xl: clamp(1.875rem, 1.35rem + 2.60vw, 3rem); /* 30→48px */
/* Line heights */
--lh-tight: 1.2;
--lh-snug: 1.35;
--lh-normal: 1.5;
--lh-relaxed: 1.65;
/* Fluid spacing scale (8pt grid, fluid from xs to xl) */
--space-0: 0;
--space-1: clamp(0.125rem, 0.11rem + 0.05vw, 0.1875rem); /* 2→3px */
--space-2: clamp(0.25rem, 0.22rem + 0.10vw, 0.375rem); /* 4→6px */
--space-3: clamp(0.375rem, 0.33rem + 0.17vw, 0.5rem); /* 6→8px */
--space-4: clamp(0.5rem, 0.44rem + 0.26vw, 0.75rem); /* 8→12px */
--space-5: clamp(0.75rem, 0.65rem + 0.42vw, 1rem); /* 12→16px */
--space-6: clamp(1rem, 0.87rem + 0.56vw, 1.5rem); /* 16→24px */
--space-7: clamp(1.25rem, 1.04rem + 0.83vw, 2rem); /* 20→32px */
--space-8: clamp(1.5rem, 1.22rem + 1.04vw, 2.5rem); /* 24→40px */
--space-10: clamp(2rem, 1.65rem + 1.46vw, 3.5rem); /* 32→56px */
--space-12: clamp(2.5rem, 2.00rem + 2.08vw, 4.5rem); /* 40→72px */
/* Touch target minimum (WCAG 2.5.5 AA is 44×44 CSS px) */
--tap-min: 44px;
/* Container widths */
--container-sm: 640px;
--container-md: 768px;
--container-lg: 1024px;
--container-xl: 1280px;
--container-2xl: 1536px;
/* Motion tokens */
--motion-fast: 120ms;
--motion-base: 200ms;
--motion-slow: 320ms;
--motion-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* Z-index scale */
--z-base: 0;
--z-sticky: 10;
--z-drawer-backdrop: 40;
--z-drawer: 50;
--z-dropdown: 60;
--z-modal: 100;
--z-toast: 200;
--z-tooltip: 300;
--z-focus: 999;
/* Focus ring */
--focus-ring-color: #60a5fa; /* light blue, visible on dark bg */
--focus-ring-offset: 2px;
--focus-ring-width: 2px;
/* Safe area insets (iOS notch, Android gesture areas) */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
}
/* Honor OS-level reduced motion — apply across the entire app.
Anything that relies on animation for state feedback (toasts,
drawer slide, spinner) must still convey state without motion.
Spinners use opacity pulses under the same media query. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
/* Honor OS-level contrast preference */
@media (prefers-contrast: more) {
:root {
--focus-ring-width: 3px;
--focus-ring-color: #ffffff;
}
}
/* High-DPI tuning: tighten 1px borders on >= 2x DPR so they read as
hairlines and don't visually bloom. */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
:root {
--hairline: 0.5px;
}
}
@media not all and (-webkit-min-device-pixel-ratio: 2), not all and (min-resolution: 2dppx) {
:root {
--hairline: 1px;
}
}