Compare commits

..

2 Commits

Author SHA1 Message Date
Devin AI
ded7d24924 PR AA follow-up: manual-rollback loud-failure summary + keep-min-5 backup-prune cron + root-only initial-keys handoff file
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 7s
CI / Frontend Build (pull_request) Failing after 6s
CI / Frontend E2E Tests (pull_request) Failing after 7s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Orchestrator Unit Tests (pull_request) Failing after 6s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 5s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 20s
Code Quality / Code Quality Checks (pull_request) Failing after 7s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
- deploy-currencicombo-8604.sh: on readiness timeout, print loud failure
  summary (journalctl tails + exact --rollback command with specific
  backup path) instead of silently exiting. Deliberately does NOT
  auto-rollback; first cutovers often fail because of env/migration
  mistakes and auto-restore hides the failure state ops needs.
- install.sh: on first run, write the three API keys + EVENT_SIGNING_SECRET
  to /root/currencicombo-first-keys.txt (0600, root:root) as a handoff
  copy. Canonical values still live in /etc/currencicombo/orchestrator.env.
  Log one pointer line (not the secrets themselves) to journald.
  Handoff file is NOT regenerated if orchestrator.env already exists.
- install-prune-cron.sh (new, opt-in): installs /etc/cron.daily/
  currencicombo-prune-backups that deletes entries older than 30 days
  from /var/lib/currencicombo/backups/ WHILE always keeping the newest
  5 regardless of age. Enforced via newest-first sort + i<KEEP_MIN skip.
- webapp-nginx.conf: drop the misleading /events/* 421 guard-rail. The
  orchestrator's SSE endpoint is /api/plans/:id/events/stream (under
  /api/), so one /api/* guard-rail covers both normal REST and SSE.
- README.md: corrected NPMplus rule table to TWO rules (/api/* with
  SSE-friendly proxy_buffering=off + 24h read_timeout + Connection ""
  + http/1.1, and /); added post-cutover smoke checks section with a
  concrete SSE streaming test that catches silent proxy_buffering=on
  misconfig; documented the /root/currencicombo-first-keys.txt handoff
  and the install-prune-cron.sh workflow; replaced stale 'not auto-pruned'
  note.

Verification:
- shellcheck --severity=warning: clean on all 3 scripts.
- bash -n: clean on install-prune-cron.sh.
- install-prune-cron.sh --dry-run: prints the pruner body with resolved
  env values as expected.
- install.sh --dry-run: walks through user/dirs/nginx-apt steps, then
  fails fast on missing psql (expected on a build box without Postgres).

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-22 23:30:34 +00:00
Devin AI
361776ab2e PR AA: Phoenix / systemd deployment scaffolding (migrate Phoenix off Next.js stub)
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 8s
CI / Frontend E2E Tests (pull_request) Failing after 8s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Orchestrator Unit Tests (pull_request) Failing after 6s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 6s
CI / Contracts Test (pull_request) Failing after 7s
Code Quality / SonarQube Analysis (pull_request) Failing after 19s
Code Quality / Code Quality Checks (pull_request) Failing after 6s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 5s
Closes the gap between Gitea main (b48eb2a, Vite portal + Node
orchestrator, 29 PRs merged, 167 tests) and what's actually serving
curucombo.xn--vov0g.com (Next.js 'ISO-20022 Combo Flow' app from an
unpushed local b118b2b checkout). After this PR is merged and the
runbook in scripts/deployment/README.md is followed on CT 8604, the
Phoenix deployment will serve d-bis/CurrenciCombo main.

Artifacts (all under scripts/deployment/):
- systemd/currencicombo-orchestrator.service  - Node orchestrator,
  EnvironmentFile=/etc/currencicombo/orchestrator.env, full systemd
  hardening (ProtectSystem=strict, PrivateTmp, no caps).
- systemd/currencicombo-webapp.service        - nginx serving Vite
  SPA on :3000 via RuntimeDirectory=/run/currencicombo-webapp.
- webapp-nginx.conf                            - self-contained nginx
  config; intentionally 421s on /api/* and /events/* so an NPMplus
  misconfig fails loudly instead of silently returning index.html.
- .env.prod.example                            - template for
  /etc/currencicombo/orchestrator.env. Documents every EXT-* blocker
  env var 1:1 with the Proxmox repo's check-external-dependencies.sh.
- install.sh                                   - idempotent host setup:
  user, dirs, nginx, fresh Postgres role/DB (--force-recreate-db to
  wipe), Redis autodetect, env file with auto-generated
  EVENT_SIGNING_SECRET + 3 API keys, systemd units enabled but not
  started. --dry-run supported.
- deploy-currencicombo-8604.sh                 - build-and-swap deploy
  driver (the script deploy-targets.json / phoenix-deploy-api calls):
  git fetch/reset, orchestrator tsc build, portal vite build with
  VITE_ORCHESTRATOR_URL baked in, migrations, timestamped backup,
  systemctl stop, rsync, systemctl start, smoke /ready + portal /,
  grep EXT-* from journalctl. --ref, --dry-run, --skip-migrate,
  --skip-build, --rollback.
- README.md                                    - architecture diagram,
  first-time setup (8 steps), NPMplus ingress rule table, subsequent-
  deploy one-liner, rollback, troubleshooting table, cutover-from-
  pre-existing-Next.js sequence, explicit list of Proxmox-side
  follow-ups.

Target-agnostic: no IP / hostname / VLAN hardcoded. The only file that
embeds the public hostname is README.md (for documentation) and the
default VITE_ORCHESTRATOR_URL in deploy-currencicombo-8604.sh (which
is overridable via env).

Single-origin NPMplus routing (confirmed with user):
  curucombo.\xe6\x9b\xbc\xe6\x9d\x8e.com/api/*     -> 10.160.0.14:8080  (orchestrator)
  curucombo.\xe6\x9b\xbc\xe6\x9d\x8e.com/events/*  -> 10.160.0.14:8080  (SSE)
  curucombo.\xe6\x9b\xbc\xe6\x9d\x8e.com/*         -> 10.160.0.14:3000  (Vite SPA)

Verified on this box (headless):
- shellcheck --severity=warning: clean on both scripts.
- bash -n: clean on both scripts.
- systemd-analyze verify: both unit files parse cleanly (only complaint
  is /usr/sbin/nginx not being executable, expected -- nginx is
  installed at deploy time).
- install.sh --dry-run: fails fast with the expected FATAL on hosts
  without psql (build box). On CT 8604 with Postgres+Redis already
  installed, it walks through every step.
- deploy-currencicombo-8604.sh --help: prints the usage.

No runtime code changes. Non-UI. Complements PR #30 (docker-compose
sandbox) which remains the local-dev path.

Proxmox-side follow-up (separate commit on /home/intlc/projects/proxmox
after this PR merges and cutover runs cleanly):
- Update phoenix-deploy-api/deploy-targets.json to point at
  scripts/deployment/deploy-currencicombo-8604.sh.
- Retire the inaccurate "Next.js webapp with ignoreBuildErrors"
  language in EXTERNAL_DEPENDENCY_BLOCKERS.md.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-22 23:05:18 +00:00
23 changed files with 58 additions and 1358 deletions

View File

@@ -1,22 +0,0 @@
name: Deploy to Phoenix
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Trigger Phoenix deployment
run: |
SHA="$(git rev-parse HEAD)"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
curl -sSf -X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
-H "Authorization: Bearer ${{ secrets.PHOENIX_DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"repo\":\"${{ gitea.repository }}\",\"sha\":\"${SHA}\",\"branch\":\"${BRANCH}\",\"target\":\"default\"}"

View File

@@ -1,291 +0,0 @@
# 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,18 +3,7 @@
<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, 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." />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solace Bank Group PLC — Treasury Management Portal</title>
</head>
<body>

View File

@@ -1,42 +1,27 @@
import { z } from "zod";
const emptyToUndefined = (value: unknown) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
};
const optionalString = () => z.preprocess(emptyToUndefined, z.string().optional());
const optionalUrl = () => z.preprocess(emptyToUndefined, z.string().url().optional());
/**
* Environment variable validation schema
*/
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
DATABASE_URL: optionalUrl(),
API_KEYS: optionalString(),
REDIS_URL: optionalUrl(),
DATABASE_URL: z.string().url().optional(),
API_KEYS: z.string().optional(),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
ALLOWED_IPS: optionalString(),
ALLOWED_IPS: z.string().optional(),
SESSION_SECRET: z.string().min(32),
JWT_SECRET: z.preprocess(emptyToUndefined, z.string().min(32).optional()),
AZURE_KEY_VAULT_URL: optionalUrl(),
AWS_SECRETS_MANAGER_REGION: optionalString(),
SENTRY_DSN: optionalUrl(),
JWT_SECRET: z.string().min(32).optional(),
AZURE_KEY_VAULT_URL: z.string().url().optional(),
AWS_SECRETS_MANAGER_REGION: z.string().optional(),
SENTRY_DSN: z.string().url().optional(),
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
// absent the notary adapter falls back to its deterministic mock.
CHAIN_138_RPC_URL: optionalUrl(),
CHAIN_138_CHAIN_ID: z.preprocess(emptyToUndefined, z.string().regex(/^\d+$/).optional()),
NOTARY_REGISTRY_ADDRESS: z.preprocess(
emptyToUndefined,
z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
),
ORCHESTRATOR_PRIVATE_KEY: z.preprocess(
emptyToUndefined,
z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
),
CHAIN_138_RPC_URL: z.string().url().optional(),
CHAIN_138_CHAIN_ID: z.string().regex(/^\d+$/).optional(),
NOTARY_REGISTRY_ADDRESS: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
ORCHESTRATOR_PRIVATE_KEY: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
});
/**
@@ -46,7 +31,7 @@ export const env = envSchema.parse({
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT || "8080",
DATABASE_URL: process.env.DATABASE_URL,
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
API_KEYS: process.env.API_KEYS,
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL,
ALLOWED_IPS: process.env.ALLOWED_IPS,
@@ -71,7 +56,7 @@ export function validateEnv() {
NODE_ENV: process.env.NODE_ENV || "development",
PORT: process.env.PORT || "8080",
DATABASE_URL: process.env.DATABASE_URL,
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
API_KEYS: process.env.API_KEYS,
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL || "info",
ALLOWED_IPS: process.env.ALLOWED_IPS,
@@ -80,10 +65,6 @@ export function validateEnv() {
AZURE_KEY_VAULT_URL: process.env.AZURE_KEY_VAULT_URL,
AWS_SECRETS_MANAGER_REGION: process.env.AWS_SECRETS_MANAGER_REGION,
SENTRY_DSN: process.env.SENTRY_DSN,
CHAIN_138_RPC_URL: process.env.CHAIN_138_RPC_URL,
CHAIN_138_CHAIN_ID: process.env.CHAIN_138_CHAIN_ID,
NOTARY_REGISTRY_ADDRESS: process.env.NOTARY_REGISTRY_ADDRESS,
ORCHESTRATOR_PRIVATE_KEY: process.env.ORCHESTRATOR_PRIVATE_KEY,
};
envSchema.parse(envWithDefaults);
console.log("✅ Environment variables validated");
@@ -98,3 +79,4 @@ export function validateEnv() {
throw error;
}
}

View File

@@ -70,28 +70,16 @@ app.get("/health", async (req, res) => {
const health = await healthCheck();
res.status(health.status === "healthy" ? 200 : 503).json(health);
});
app.get("/api/health", async (req, res) => {
const health = await healthCheck();
res.status(health.status === "healthy" ? 200 : 503).json(health);
});
app.get("/ready", async (req, res) => {
const ready = await readinessCheck();
res.status(ready ? 200 : 503).json({ ready });
});
app.get("/api/ready", async (req, res) => {
const ready = await readinessCheck();
res.status(ready ? 200 : 503).json({ ready });
});
app.get("/live", async (req, res) => {
const alive = await livenessCheck();
res.status(alive ? 200 : 503).json({ alive });
});
app.get("/api/live", async (req, res) => {
const alive = await livenessCheck();
res.status(alive ? 200 : 503).json({ alive });
});
// Metrics endpoint
app.get("/metrics", async (req, res) => {
@@ -99,11 +87,6 @@ app.get("/metrics", async (req, res) => {
const metrics = await getMetrics();
res.send(metrics);
});
app.get("/api/metrics", async (req, res) => {
res.setHeader("Content-Type", register.contentType);
const metrics = await getMetrics();
res.send(metrics);
});
// API routes with rate limiting
app.use("/api", apiLimiter);
@@ -190,3 +173,4 @@ async function start() {
}
start();

View File

@@ -23,7 +23,7 @@ HOST=0.0.0.0
############################################################
# Postgres (local to the CT per install.sh)
############################################################
DATABASE_URL=postgresql://currencicombo:replace-me-on-install@127.0.0.1:5432/currencicombo
DATABASE_URL=postgresql://currencicombo@127.0.0.1:5432/currencicombo
############################################################
# Redis (local to the CT per install.sh)
@@ -41,7 +41,7 @@ EVENT_SIGNING_SECRET=
# initiator/settler/auditor keys on first run unless set.
# Format: key1:role1,key2:role2,...
############################################################
API_KEYS=
ORCHESTRATOR_API_KEYS=
############################################################
# Chain 138 — resolves EXT-CHAIN138-CI-RPC (already resolved).

View File

@@ -112,7 +112,7 @@ already under `/api/*` — no separate `/events/*` rule is needed):
| location | upstream | proxy settings |
|---|---|---|
| `/api/*` | `http://10.160.0.14:8080` | **SSE-friendly settings apply here because the SSE route `/api/plans/:id/events/stream` is under /api/**. Use `proxy_pass http://10.160.0.14:8080;` with **no trailing slash** so `/api/...` reaches the orchestrator unchanged. Set: `proxy_http_version 1.1;`, `proxy_set_header Connection "";`, `proxy_buffering off;`, `proxy_cache off;`, `proxy_read_timeout 24h;`, `proxy_send_timeout 24h;`. Standard forwarding: `proxy_set_header Host $host;`, `X-Real-IP $remote_addr;`, `X-Forwarded-For $proxy_add_x_forwarded_for;`, `X-Forwarded-Proto $scheme;`. The slight overhead of `proxy_buffering off` on plain REST calls is negligible for this workload. |
| `/api/*` | `http://10.160.0.14:8080` | **SSE-friendly settings apply here because the SSE route `/api/plans/:id/events/stream` is under /api/**. Set: `proxy_http_version 1.1;`, `proxy_set_header Connection "";`, `proxy_buffering off;`, `proxy_cache off;`, `proxy_read_timeout 24h;`, `proxy_send_timeout 24h;`. Standard forwarding: `proxy_set_header Host $host;`, `X-Real-IP $remote_addr;`, `X-Forwarded-For $proxy_add_x_forwarded_for;`, `X-Forwarded-Proto $scheme;`. The slight overhead of `proxy_buffering off` on plain REST calls is negligible for this workload. |
| `/` | `http://10.160.0.14:3000` | Vite SPA. Default upstream. No special settings. |
If you skip the `/api/*` rule, the nginx in `webapp-nginx.conf`
@@ -237,7 +237,7 @@ Phoenix previously had an older Next.js "ISO-20022 Combo Flow" app in
```
3. Run `install.sh` (writes the new units, new nginx, new env). On an already-set-up host this is idempotent: it preserves `/etc/currencicombo/orchestrator.env` if it already exists.
4. Run `deploy-currencicombo-8604.sh`.
5. Apply the NPMplus `/api` + `/` path rules.
5. Apply the NPMplus `/api` + `/events` path rules.
6. Smoke from outside the CT: `curl -skI https://curucombo.xn--vov0g.com/ && curl -sk https://curucombo.xn--vov0g.com/api/ready`.
## Proxmox-side follow-up (not in this PR)

View File

@@ -107,31 +107,20 @@ if [[ "${DO_ROLLBACK}" -eq 1 ]]; then
fi
# ----- 1. git ---------------------------------------------------------
run "install -d -o '${CC_USER}' -g '${CC_USER}' -m 0755 '${CC_REPO_DIR}'"
run "chown -R '${CC_USER}:${CC_USER}' '${CC_REPO_DIR}'"
if [[ ! -d "${CC_REPO_DIR}/.git" && "${CC_GIT_REF}" != "local" ]]; then
if [[ ! -d "${CC_REPO_DIR}/.git" ]]; then
log "cloning ${CC_GIT_REMOTE}${CC_REPO_DIR}"
run "install -d -o '${CC_USER}' -g '${CC_USER}' -m 0755 '${CC_REPO_DIR}'"
runcc "git clone '${CC_GIT_REMOTE}' '${CC_REPO_DIR}'"
fi
if [[ -d "${CC_REPO_DIR}/.git" && "${CC_GIT_REF}" != "local" ]]; then
runcc "cd '${CC_REPO_DIR}' && git fetch --prune origin"
runcc "cd '${CC_REPO_DIR}' && git reset --hard 'origin/${CC_GIT_REF}'"
REF_SHA="$(sudo -u "${CC_USER}" git -C "${CC_REPO_DIR}" rev-parse --short HEAD 2>/dev/null || echo unknown)"
log "repo at ${CC_GIT_REF} = ${REF_SHA}"
else
REF_SHA="local"
log "using staged local workspace from ${CC_REPO_DIR}"
fi
runcc "cd '${CC_REPO_DIR}' && git fetch --prune origin"
runcc "cd '${CC_REPO_DIR}' && git reset --hard 'origin/${CC_GIT_REF}'"
REF_SHA="$(sudo -u "${CC_USER}" git -C "${CC_REPO_DIR}" rev-parse --short HEAD 2>/dev/null || echo unknown)"
log "repo at ${CC_GIT_REF} = ${REF_SHA}"
# ----- 2. orchestrator build -----------------------------------------
if [[ "${SKIP_BUILD}" -eq 0 ]]; then
log "building orchestrator"
if [[ -f "${CC_REPO_DIR}/orchestrator/package-lock.json" ]]; then
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm ci --no-audit --no-fund"
else
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm install --no-audit --no-fund"
fi
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm ci --no-audit --no-fund"
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm run build"
log "building portal (VITE_ORCHESTRATOR_URL=${VITE_ORCHESTRATOR_URL})"
runcc "cd '${CC_REPO_DIR}' && npm ci --include=optional --no-audit --no-fund || npm ci --include=optional --force --no-audit --no-fund"
@@ -169,7 +158,7 @@ log "rsyncing new build into ${CC_APP_HOME}"
runcc "rsync -a --delete '${CC_REPO_DIR}/orchestrator/dist/' '${CC_APP_HOME}/orchestrator/dist/'"
runcc "rsync -a '${CC_REPO_DIR}/orchestrator/node_modules/' '${CC_APP_HOME}/orchestrator/node_modules/'"
runcc "cp '${CC_REPO_DIR}/orchestrator/package.json' '${CC_APP_HOME}/orchestrator/package.json'"
runcc "if [[ -f '${CC_REPO_DIR}/orchestrator/package-lock.json' ]]; then cp '${CC_REPO_DIR}/orchestrator/package-lock.json' '${CC_APP_HOME}/orchestrator/package-lock.json'; else rm -f '${CC_APP_HOME}/orchestrator/package-lock.json'; fi"
runcc "cp '${CC_REPO_DIR}/orchestrator/package-lock.json' '${CC_APP_HOME}/orchestrator/package-lock.json'"
# Webapp: dist/
runcc "rsync -a --delete '${CC_REPO_DIR}/dist/' '${CC_APP_HOME}/webapp/dist/'"

View File

@@ -44,9 +44,6 @@ log() { printf '[install] %s\n' "$*" >&2; }
warn() { printf '[install][WARN] %s\n' "$*" >&2; }
die() { printf '[install][FATAL] %s\n' "$*" >&2; exit 1; }
run() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[install][dry-run] %s\n' "$*" >&2; else eval "$*"; fi; }
sql_escape() {
printf "%s" "$1" | sed "s/'/''/g"
}
usage() {
cat <<'USAGE'
@@ -144,16 +141,16 @@ if ! pg_db_exists; then
log "creating Postgres database ${APP_USER}"
run "sudo -u postgres psql -c \"CREATE DATABASE ${APP_USER} OWNER ${APP_USER};\""
fi
# Peer auth from the currencicombo OS user → currencicombo DB role "just works"
# on Debian-style pg_hba (local all all peer). No password needed.
# ----------------------------------------------------------------------
# 4. Redis
# ----------------------------------------------------------------------
if systemctl list-unit-files | grep -q '^redis-server\.service'; then
run "systemctl start redis-server.service || true"
run "systemctl enable redis-server.service >/dev/null 2>&1 || true"
run systemctl enable --now redis-server
elif systemctl list-unit-files | grep -q '^redis\.service'; then
run "systemctl start redis.service || true"
run "systemctl enable redis.service >/dev/null 2>&1 || true"
run systemctl enable --now redis
elif command -v redis-cli >/dev/null 2>&1; then
warn "redis-cli present but no redis-server.service / redis.service unit — assuming external Redis"
else
@@ -174,16 +171,8 @@ else
INIT_KEY="$(openssl rand -hex 24)"
SETT_KEY="$(openssl rand -hex 24)"
AUD_KEY="$(openssl rand -hex 24)"
DB_PASSWORD="$(openssl rand -hex 24)"
DB_PASSWORD_SQL="$(sql_escape "${DB_PASSWORD}")"
API_KEYS_VALUE="${INIT_KEY}:initiator,${SETT_KEY}:settler,${AUD_KEY}:auditor"
DATABASE_URL="postgresql://${APP_USER}:${DB_PASSWORD}@127.0.0.1:5432/${APP_USER}"
log "setting Postgres password for role ${APP_USER}"
run "sudo -u postgres psql -c \"ALTER ROLE ${APP_USER} WITH LOGIN PASSWORD '${DB_PASSWORD_SQL}';\""
run "sed -i 's|^EVENT_SIGNING_SECRET=.*|EVENT_SIGNING_SECRET=${SECRET}|' '${ENV_FILE}'"
run "sed -i 's|^API_KEYS=.*|API_KEYS=${API_KEYS_VALUE}|' '${ENV_FILE}'"
run "sed -i 's|^DATABASE_URL=.*|DATABASE_URL=${DATABASE_URL}|' '${ENV_FILE}'"
run "grep -q '^ORCHESTRATOR_API_KEYS=' '${ENV_FILE}' && sed -i 's|^ORCHESTRATOR_API_KEYS=.*|ORCHESTRATOR_API_KEYS=${API_KEYS_VALUE}|' '${ENV_FILE}' || printf '\nORCHESTRATOR_API_KEYS=%s\n' '${API_KEYS_VALUE}' >> '${ENV_FILE}'"
run "sed -i 's|^ORCHESTRATOR_API_KEYS=.*|ORCHESTRATOR_API_KEYS=${INIT_KEY}:initiator,${SETT_KEY}:settler,${AUD_KEY}:auditor|' '${ENV_FILE}'"
# Write a root-only handoff file so ops can grab the keys without
# scraping journald or reading the env file. The canonical copy lives
# in ${ENV_FILE}; delete this file once the keys are in your password
@@ -208,11 +197,9 @@ EVENT_SIGNING_SECRET=${SECRET}
ORCHESTRATOR_API_KEY_INITIATOR=${INIT_KEY}
ORCHESTRATOR_API_KEY_SETTLER=${SETT_KEY}
ORCHESTRATOR_API_KEY_AUDITOR=${AUD_KEY}
DATABASE_URL=${DATABASE_URL}
# As it appears in ${ENV_FILE}:
API_KEYS=${API_KEYS_VALUE}
ORCHESTRATOR_API_KEYS=${API_KEYS_VALUE}
ORCHESTRATOR_API_KEYS=${INIT_KEY}:initiator,${SETT_KEY}:settler,${AUD_KEY}:auditor
EOF
chmod 0600 "${FIRST_KEYS_FILE}"
chown root:root "${FIRST_KEYS_FILE}"
@@ -221,7 +208,6 @@ EOF
fi
log " generated EVENT_SIGNING_SECRET (64 hex)"
log " generated 3 API keys (initiator/settler/auditor)"
log " generated local Postgres password for ${APP_USER}"
log " initial secrets written to ${FIRST_KEYS_FILE} (0600) — record in password manager, then 'shred -u ${FIRST_KEYS_FILE}'"
fi

View File

@@ -10,7 +10,7 @@ User=currencicombo
Group=currencicombo
RuntimeDirectory=currencicombo-webapp
RuntimeDirectoryMode=0755
ExecStart=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -g 'daemon off; pid /run/currencicombo-webapp/nginx.pid;'
ExecStart=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -e /var/log/currencicombo/webapp-nginx.error.log -g 'daemon off; pid /run/currencicombo-webapp/nginx.pid;'
ExecReload=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -s reload
Restart=on-failure
RestartSec=5

View File

@@ -1,5 +1,4 @@
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';
@@ -8,45 +7,10 @@ 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);
@@ -61,7 +25,7 @@ function saveWorkspace(state: Record<string, unknown>) {
} catch { /* ignore */ }
}
function WorkspaceApp() {
export default function App() {
const saved = useRef(loadWorkspace());
const [activityTab, setActivityTab] = useState<ActivityTab>(saved.current?.activityTab || 'builder');

View File

@@ -10,7 +10,6 @@ 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 }) {
@@ -45,9 +44,7 @@ export default function Portal() {
}
return (
<>
<SkipToContent targetId="main-content" />
<Routes>
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
@@ -169,8 +166,7 @@ export default function Portal() {
/>
<Route path="*" element={<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />} />
</Routes>
</>
</Routes>
);
}

View File

@@ -1,16 +0,0 @@
/**
* 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

@@ -1,11 +0,0 @@
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,11 +1,10 @@
import { useEffect, useState } from 'react';
import { 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, Menu, X,
ExternalLink, ChevronDown, GitBranch
} from 'lucide-react';
const navItems = [
@@ -27,53 +26,12 @@ 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);
@@ -84,23 +42,7 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
<div className="portal-layout">
<div className="portal-topbar">
<div className="portal-topbar-left">
{/* 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'); }}
>
<div className="portal-logo" onClick={() => navigate('/dashboard')}>
<Building2 size={22} color="#3b82f6" />
{!collapsed && (
<div className="portal-logo-text">
@@ -120,34 +62,28 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
<div className="portal-topbar-right">
<div className="portal-notif-wrapper">
<button
className="portal-icon-btn"
aria-label="Notifications"
aria-haspopup="true"
aria-expanded={showNotifications}
onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}
>
<button className="portal-icon-btn" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
<Bell size={18} />
<span className="portal-notif-badge" aria-label="3 unread">3</span>
<span className="portal-notif-badge">3</span>
</button>
{showNotifications && (
<div className="portal-dropdown notifications-dropdown" role="menu">
<div className="portal-dropdown notifications-dropdown">
<div className="portal-dropdown-header">Notifications</div>
<div className="portal-dropdown-item warning" role="menuitem">
<div className="portal-dropdown-item warning">
<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" role="menuitem">
<div className="portal-dropdown-item info">
<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" role="menuitem">
<div className="portal-dropdown-item">
<span className="dropdown-dot success" />
<div>
<div className="dropdown-title">Report Ready</div>
@@ -159,13 +95,7 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
<div className="portal-user-wrapper">
<button
className="portal-user-btn"
aria-label="Account menu"
aria-haspopup="true"
aria-expanded={showUserMenu}
onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}
>
<button className="portal-user-btn" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
<div className="portal-avatar">
<User size={14} />
</div>
@@ -176,12 +106,12 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
<ChevronDown size={12} />
</button>
{showUserMenu && (
<div className="portal-dropdown user-dropdown" role="menu">
<div className="portal-dropdown user-dropdown">
<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" aria-label="Copy wallet address"><Copy size={12} /></button>
<button className="copy-btn" onClick={copyAddress} title="Copy address"><Copy size={12} /></button>
</div>
<div className="portal-wallet-bal">
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
@@ -189,14 +119,14 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
</div>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action" role="menuitem" onClick={() => navigate('/settings')}>
<button className="portal-dropdown-action" onClick={() => navigate('/settings')}>
<Settings size={14} /> Settings
</button>
<button className="portal-dropdown-action" role="menuitem" onClick={() => window.open('https://etherscan.io', '_blank')}>
<button className="portal-dropdown-action" 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" role="menuitem" onClick={disconnect}>
<button className="portal-dropdown-action danger" onClick={disconnect}>
<LogOut size={14} /> Disconnect Wallet
</button>
</div>
@@ -206,17 +136,7 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
</div>
<div className="portal-body">
{/* 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' : ''}`}
>
<nav className={`portal-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="portal-nav-items">
{navItems.map(item => {
const Icon = item.icon;
@@ -227,7 +147,6 @@ 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>}
@@ -238,25 +157,17 @@ 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)}
aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
>
<button className="portal-collapse-btn" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</nav>
<main id="main-content" tabIndex={-1} className="portal-content">
<main className="portal-content">
{children}
</main>
</div>

View File

@@ -1,55 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,12 +0,0 @@
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,13 +1,7 @@
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'

View File

@@ -1,71 +0,0 @@
/* ═══════════════════════════════════════════════════════════════
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);
}
}

View File

@@ -1,438 +0,0 @@
/* ═══════════════════════════════════════════════════════════════
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; }
}

View File

@@ -1,132 +0,0 @@
/* ═══════════════════════════════════════════════════════════════
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;
}
}