Responsive UX/UI: tokens, hooks, drawer nav, workspace gate, a11y primitives #33
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.
|
||||
13
index.html
13
index.html
@@ -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>
|
||||
|
||||
38
src/App.tsx
38
src/App.tsx
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
16
src/components/a11y/SkipToContent.tsx
Normal file
16
src/components/a11y/SkipToContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/a11y/VisuallyHidden.tsx
Normal file
11
src/components/a11y/VisuallyHidden.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
55
src/hooks/useBreakpoint.ts
Normal file
55
src/hooks/useBreakpoint.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
37
src/hooks/useMediaQuery.ts
Normal file
37
src/hooks/useMediaQuery.ts
Normal 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);
|
||||
}
|
||||
10
src/hooks/useOrientation.ts
Normal file
10
src/hooks/useOrientation.ts
Normal 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';
|
||||
}
|
||||
12
src/hooks/useReducedMotion.ts
Normal file
12
src/hooks/useReducedMotion.ts
Normal 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)');
|
||||
}
|
||||
@@ -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
71
src/styles/a11y.css
Normal 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
438
src/styles/responsive.css
Normal 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
132
src/styles/tokens.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user