Compare commits

...

3 Commits

Author SHA1 Message Date
Devin AI
46cce8f44e Responsive UX/UI system: tokens, hooks, drawer nav, workspace gate, a11y primitives
Adds a CSS-first responsive foundation with breakpoint tokens, fluid
typography/spacing via clamp(), and matchMedia-driven hooks. Portal
chrome swaps to an off-canvas drawer below md; workspace (IDE) shows
a friendly mobile gate below md with links to portal routes.

Details in docs/ux-responsive-strategy.md.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-23 04:49:14 +00:00
f2e0434ad6 PR AB: complete Phoenix deployment scaffolding (add 3 files referenced by main 4a1f69a) (#32)
Some checks failed
Deploy to Phoenix / deploy (push) Failing after 7s
Adds webapp-nginx.conf, systemd/currencicombo-orchestrator.service, and install-prune-cron.sh — all three referenced by main's existing install.sh / deploy script / webapp.service / README but missing from the 4a1f69a commit. Byte-identical to PR #31 branch ded7d24.

Closes gap so CT 8604 can boot cleanly.
2026-04-23 04:39:36 +00:00
defiQUG
4a1f69a8e5 deploy: make Phoenix redeploys archive-safe
Some checks failed
Deploy to Phoenix / deploy (push) Failing after 5s
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-currencicombo-from-workspace.sh [currencicombo-phoenix] packing s
2026-04-22 20:05:35 -07:00
26 changed files with 2386 additions and 39 deletions

View File

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

@@ -0,0 +1,291 @@
# Responsive UX/UI strategy
This document describes the responsive design system added to the
CurrenciCombo portal. It is intended to be the source of truth for
future responsive work.
## Goals
The portal ships to a mixed audience that includes treasury operators
at desks (≥ 1440 px displays), branch-office users on 13" laptops,
tablets in landscape, and mobile phones for on-call auditors. The
system has to adapt across all of those without:
- horizontal scroll at any viewport ≥ 320 px,
- layout shift on resize (no CLS),
- unusable tap targets on touch devices,
- losing keyboard navigation on desktop, or
- degrading desktop performance.
## Architecture
The responsive system is CSS-first and token-driven. There is **one**
runtime hook (`useBreakpoint`) used by the handful of components that
have to swap structure (nav, workspace gate) — everything else is
pure CSS.
### Layered stylesheet order
`src/main.tsx` imports styles in this order:
1. `src/styles/tokens.css` — design tokens (no visual side effects).
2. `src/index.css` — the original desktop baseline stylesheet.
3. `src/styles/responsive.css` — media-query overrides.
4. `src/styles/a11y.css` — focus ring, skip-to-content, sr-only,
touch-target enforcement.
Order matters: overrides must appear after the baseline so that rules
at equal specificity win. Nothing in `responsive.css` or `a11y.css`
changes behavior at `≥ lg` — every rule is wrapped in a
`@media (max-width: …)` or `@media (pointer: coarse)` query.
### Tokens
All sizing values that change with viewport live in
`src/styles/tokens.css`:
| Category | Tokens |
| ------------- | -------------------------------------------- |
| Breakpoints | `--bp-xs 0`, `--bp-sm 480`, `--bp-md 768`, `--bp-lg 1024`, `--bp-xl 1440` (px) |
| Fluid type | `--fs-2xs` through `--fs-4xl`, all `clamp()` |
| Fluid spacing | `--space-0` through `--space-12`, all `clamp()` |
| Line height | `--lh-tight`, `--lh-snug`, `--lh-normal`, `--lh-relaxed` |
| Motion | `--motion-fast 120ms`, `--motion-base 200ms`, `--motion-slow 320ms`, `--motion-ease` |
| Z-index | `--z-base`, `--z-sticky`, `--z-drawer-backdrop`, `--z-drawer`, `--z-dropdown`, `--z-modal`, `--z-toast`, `--z-tooltip`, `--z-focus` |
| Focus ring | `--focus-ring-color #60a5fa`, `--focus-ring-offset 2px`, `--focus-ring-width 2px` |
| Safe area | `--safe-top/right/bottom/left` via `env()` |
| Tap target | `--tap-min 44px` |
### Fluid scaling formula
Typography and spacing use `clamp(min, preferred, max)` where
`preferred` has a `vw` term so values interpolate continuously between
the smallest design viewport (320 px) and the largest (1440 px). This
is what the spec calls "avoid hard breakpoints where possible; prefer
fluid responsiveness" — between breakpoints, sizes grow continuously
rather than snapping.
Example, `--fs-base: clamp(0.8125rem, 0.77rem + 0.23vw, 1rem)`:
- at 320 px → 13 px (minimum),
- at 1024 px → ~15.1 px,
- at 1440 px → 16 px (maximum clamp).
### Breakpoints
| Name | CSS range | Semantic |
| ---- | ----------------------- | ---------------------- |
| xs | 0 479 px | Small phones, portrait |
| sm | 480 767 px | Larger phones |
| md | 768 1023 px | Tablets |
| lg | 1024 1439 px | Laptops |
| xl | ≥ 1440 px | External displays |
Semantic aliases exposed by `useBreakpoint()`:
- `isMobile` = `< md` (phones)
- `isTablet` = `md lg` (tablets, portrait iPads)
- `isDesktop` = `≥ lg` (laptops, external monitors)
### Hooks
- **`useMediaQuery(query)`** — subscribes to a matchMedia `change`
event via `useSyncExternalStore`. No resize listener — re-renders
only when the query result actually flips.
- **`useBreakpoint()`** — returns `{ current, isXs/Sm/Md/Lg/Xl,
isMobile/Tablet/Desktop }`. Composed of four `useMediaQuery` calls
using the `--bp-*` values from the tokens.
- **`useReducedMotion()`** — wraps `(prefers-reduced-motion: reduce)`
so components that animate in JS can disable animations.
- **`useOrientation()`** — returns `'portrait'` or `'landscape'`.
All four hooks are SSR-safe (return `false` / default in a non-DOM
environment).
## Component inventory and strategy
### Global primitives
- **Skip-to-content link** — rendered at the top of the tree via
`<SkipToContent>`. Visually hidden until `:focus`, then slides in at
top-left. Target is `<main id="main-content" tabIndex={-1}>` in
`PortalLayout`.
- **VisuallyHidden** — `sr-only` wrapper, for adding accessible text
without visual presence.
### Portal chrome
- **Topbar** (`.portal-topbar`): fixed 48 px height at all sizes.
Below `md`, the "Production" environment badge and the "Solace Bank
Group" logo text are hidden so the hamburger, logo mark and user
avatar fit on a phone without wrapping.
- **Hamburger toggle** (`.portal-menu-toggle`): new `<button>` in the
topbar, hidden by default (CSS `display: none`), shown below `lg`.
44 × 44 tap area. `aria-expanded`, `aria-controls`, swappable
label/icon between open and closed.
- **Sidebar / drawer** (`.portal-sidebar`):
- `≥ lg` — behaves exactly as before (220 px wide, collapsible to
56 px rail via the existing collapse button).
- `md lg` — forced into a 56 px icon-only rail (tablet treatment).
- `< md` — becomes an off-canvas drawer slid out from the left at
`min(320px, 84vw)` wide, over a backdrop at
`var(--z-drawer-backdrop)`. Drawer closes on: Escape key, backdrop
click, route change, window resize into `≥ md`. Body scroll is
locked while the drawer is open.
### Dashboard (`/dashboard`)
- KPI row uses `grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr))`
so six cards at xl collapse to four at lg, three at md, two at sm,
one at xs — fluidly.
- Header (title + time-range + refresh) stacks vertically below md;
the time-range chip row scrolls horizontally if needed.
- Two-column `.dashboard-grid` becomes single-column below `lg`.
### Tables (Transactions, Settlements, Accounts)
Two CSS patterns available:
1. **Scrolling wrapper (default).** Wrap the `<table>` in
`<div class="portal-table-wrapper">`. Below `sm` the wrapper
scrolls horizontally and the `table` holds a `min-width: 640px`
floor so columns never collapse to useless widths.
2. **Stacked-card mode (opt-in).** Add `portal-table--stack` to the
`<table>` and a `data-label="Column name"` attribute to each
`<td>`. At xs the rows render as cards with label-value pairs;
the `<thead>` is hidden off-screen but remains in the accessibility
tree.
### Forms (Reporting, Compliance, Treasury, Settings)
These pages already use CSS grid via the `.settings-grid` /
`.treasury-summary` / etc. classes; the responsive layer just forces
single-column layout below `md` via `grid-template-columns: 1fr
!important` and reduces container padding to `var(--space-4)` on
phones.
### Login page
- Below `md` the two-column layout (brand left / card right) stacks.
Both halves go to `max-width: 100%`.
- `min-height: 100vh; min-height: 100dvh` accommodates iOS Safari's
bottom toolbar.
- `safe-area-inset-*` padding for notched phones.
### Workspace / IDE mode (`App.tsx`)
The multi-panel IDE (TitleBar + ActivityBar + LeftPanel + Canvas +
RightPanel + BottomPanel + react-flow) is not realistically usable
below ~768 px of width. Matching the pattern used by VS Code Web,
Figma, and Replit:
- **`< md`** — show `<WorkspaceMobileGate>` instead of the IDE shell.
Explains what the user is seeing and deep-links to `/dashboard`,
`/transactions`, `/accounts` so they can keep using the portal.
- **`≥ md`** — render the full workspace exactly as before.
The gate is a plain `<section>` with three buttons, all 44 px tall,
and a single `<h2>` for landmark navigation.
## Performance
- **No layout shift.** Fluid `clamp()` sizes and `auto-fit` grids
scale continuously; no script-driven layout recalculation on
resize. `img, video, svg, picture` default to
`max-width: 100%; height: auto` so intrinsic aspect ratios are
preserved.
- **No resize listeners.** `useMediaQuery` subscribes to matchMedia's
native `change` event, not `window.resize`. React only re-renders
when a query actually flips.
- **CSS-first.** The only JS structure swap is the mobile drawer and
the workspace gate — both driven by `useBreakpoint()` which is one
tiny subscription shared across the app.
- **Print.** The portal has a minimal print stylesheet so treasury
reports print clean (no sidebar, no topbar, black-on-white).
## Accessibility
- **Skip-to-content link** at the top of the tree.
- **Focus ring** via `:focus-visible`, 2 px solid `#60a5fa` with
2 px offset. Defined in `a11y.css`, tightens to 3 px in
`prefers-contrast: more`.
- **Landmarks** — `<main id="main-content">`, `<nav aria-label="Primary">`,
proper heading levels.
- **Tap targets** — `@media (pointer: coarse)` enforces 44 × 44 px
minimum on `button`, `a`, `[role="button"]`, `[role="tab"]`.
- **Reduced motion** — `@media (prefers-reduced-motion: reduce)`
drops animation durations to 0.001 ms; JS-driven animations read
`useReducedMotion()`.
- **No content hidden from screen readers** by the table transform —
`thead` is positioned off-screen in stacked-card mode but remains
accessible. Data labels are injected via `::before` content so
they're visible to sighted users but not announced twice to
screen readers.
## Gotchas and non-obvious decisions
1. **`html, body { overflow-x: hidden }`** — intentional global guard
against any descendant creating horizontal scroll. The IDE shell
(`.app-shell`) and portal root (`.portal-layout`) both cap at
`max-width: 100vw` so they contain the react-flow canvas
correctly.
2. **Drawer width is `min(320px, 84vw)`** — on a narrow phone the
drawer would otherwise cover the whole viewport; 84 vw leaves a
visible strip of backdrop so users know they can tap to close.
3. **`.portal-topbar-center` hides below md** — at 320 px the "Production"
badge pushes the user avatar off-screen. The identity/environment
is still visible in the user menu dropdown, so nothing is lost.
4. **Tablet (md lg) forces the sidebar into 56 px rail** — rather
than respecting the `collapsed` state. Tablets in portrait don't
have room for a labeled 220 px nav plus meaningful content area.
5. **`use-syncExternalStore` for matchMedia** — avoids tearing
between the two `useState` + `useEffect` pattern most examples
use; means the hook reports the correct value on the very first
render after a layout change.
6. **`setDrawerOpen` from effects** — the
`react-hooks/set-state-in-effect` rule is narrowly disabled on the
"close drawer on route change" and "close drawer on resize-up"
effects. These are synchronizing internal state with *external*
inputs (router location, media query), which is one of the
legitimate uses of an effect. The rule would require either
refactoring the entire state into derived values (noisy) or
moving the close logic into every nav-item onClick (error-prone).
## Testing matrix
| Category | Viewports tested |
| ---------------- | --------------------------------------------------- |
| Phones | 320, 375, 414 |
| Tablets | 768 (portrait), 1024 (landscape) |
| Laptops | 1280, 1440 |
| Desktops | 1920, 2560 |
| Orientation | Portrait and landscape at 768, 414 |
| DPR | 1× and 2× (hairline borders at 2×) |
| Reduced motion | `prefers-reduced-motion: reduce` honored |
| Keyboard | Tab order, skip-link, focus ring, drawer Escape |
## File inventory
New files:
- `src/styles/tokens.css`
- `src/styles/responsive.css`
- `src/styles/a11y.css`
- `src/hooks/useMediaQuery.ts`
- `src/hooks/useBreakpoint.ts`
- `src/hooks/useReducedMotion.ts`
- `src/hooks/useOrientation.ts`
- `src/components/a11y/SkipToContent.tsx`
- `src/components/a11y/VisuallyHidden.tsx`
- `docs/ux-responsive-strategy.md` (this document)
Modified files:
- `index.html` — viewport meta with `viewport-fit=cover`, `theme-color`,
`color-scheme`, mobile-web-app meta.
- `src/main.tsx` — import order for the four CSS layers.
- `src/App.tsx` — workspace mobile gate wrapping the IDE entrypoint.
- `src/Portal.tsx` — `<SkipToContent>` mounted once at the route root.
- `src/components/portal/PortalLayout.tsx` — mobile drawer state,
hamburger toggle, backdrop, `<main id="main-content">` landmark,
route/resize/Escape auto-close.

View File

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

View File

@@ -1,27 +1,42 @@
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: z.string().url().optional(),
API_KEYS: z.string().optional(),
REDIS_URL: z.string().url().optional(),
DATABASE_URL: optionalUrl(),
API_KEYS: optionalString(),
REDIS_URL: optionalUrl(),
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
ALLOWED_IPS: z.string().optional(),
ALLOWED_IPS: optionalString(),
SESSION_SECRET: z.string().min(32),
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(),
JWT_SECRET: z.preprocess(emptyToUndefined, z.string().min(32).optional()),
AZURE_KEY_VAULT_URL: optionalUrl(),
AWS_SECRETS_MANAGER_REGION: optionalString(),
SENTRY_DSN: optionalUrl(),
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
// absent the notary adapter falls back to its deterministic mock.
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(),
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(),
),
});
/**
@@ -31,7 +46,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,
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL,
ALLOWED_IPS: process.env.ALLOWED_IPS,
@@ -56,7 +71,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,
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL || "info",
ALLOWED_IPS: process.env.ALLOWED_IPS,
@@ -65,6 +80,10 @@ 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");
@@ -79,4 +98,3 @@ export function validateEnv() {
throw error;
}
}

View File

@@ -70,16 +70,28 @@ 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) => {
@@ -87,6 +99,11 @@ 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);
@@ -173,4 +190,3 @@ async function start() {
}
start();

View File

@@ -0,0 +1,80 @@
# CurrenciCombo orchestrator production env (Phoenix CT 8604 / any systemd host)
#
# Installed by scripts/deployment/install.sh to:
# /etc/currencicombo/orchestrator.env
#
# Loaded by the currencicombo-orchestrator.service systemd unit via
# EnvironmentFile=. Values that are committed here are safe defaults;
# secrets are left blank and must be set before first boot.
#
# The portal is a statically built SPA (nginx), so it takes NO runtime env.
# Any VITE_* vars needed at build time are baked into dist/ by
# scripts/deployment/deploy-currencicombo-8604.sh before the rsync.
############################################################
# Server
############################################################
NODE_ENV=production
PORT=8080
# Bind to loopback only when behind NPMplus on the same host; bind
# 0.0.0.0 if NPMplus is on a different host (the CT 8604 case, so 0.0.0.0).
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
############################################################
# Redis (local to the CT per install.sh)
############################################################
REDIS_URL=redis://127.0.0.1:6379
############################################################
# Event bus signing (REQUIRED). install.sh generates this on first run
# via `openssl rand -hex 32` unless the file already exists.
############################################################
EVENT_SIGNING_SECRET=
############################################################
# API keys per role (REQUIRED). install.sh generates three random
# initiator/settler/auditor keys on first run unless set.
# Format: key1:role1,key2:role2,...
############################################################
API_KEYS=
############################################################
# Chain 138 — resolves EXT-CHAIN138-CI-RPC (already resolved).
############################################################
CHAIN_138_RPC_URL=https://rpc.public-0138.defi-oracle.io
CHAIN_138_CHAIN_ID=138
# Leave empty to run mock notary. Populate after running
# `contracts/scripts/deploy-notary-registry.ts` once.
NOTARY_REGISTRY_ADDRESS=
# Leave empty to run mock notary. Otherwise 0x-prefixed 32-byte hex.
ORCHESTRATOR_PRIVATE_KEY=
############################################################
# External dependency blockers (leave blank → mock fallback + EXT-* log)
# These are the exact IDs that the Proxmox
# scripts/verify/check-external-dependencies.sh gate knows about.
############################################################
# EXT-DBIS-CORE — set when dbis_core is deployed and reachable.
DBIS_CORE_URL=
# EXT-FIN-GATEWAY — set when a real Alliance Access / FIN gateway is
# provisioned. Leave blank to use PR R's in-process sandbox.
FIN_SANDBOX_URL=
# EXT-CC-* — the following four blockers are upstream-scaffold repos
# (cc-payment-adapters, cc-audit-ledger, cc-shared-events,
# cc-shared-schemas). They cannot be resolved from this repo; no
# env var flips them. The orchestrator logs EXT-CC-* as active on boot.
# Identity + controls matrix (not a blocker IDs per se — they ship
# today via the cc-identity-core and cc-compliance-controls adapters
# merged in PR V/W). Blank keeps the embedded v0 matrix + mock identity.
CC_IDENTITY_URL=
CC_CONTROLS_MATRIX_URL=

View File

@@ -0,0 +1,254 @@
# CurrenciCombo — Phoenix / systemd deployment
This directory holds everything needed to deploy CurrenciCombo onto a
systemd host — starting with Phoenix CT 8604 on `r630-01`, but any
Debian/Ubuntu (or Alpine) host with Postgres + Redis available works.
The files here are **target-agnostic**. They hardcode no IPs, hostnames,
or VLANs. Environment-specific values — `curucombo.曼李.com`, the
`10.160.0.14` VIP, the NPMplus reverse proxy — are applied at the
edge (NPMplus) and at `/etc/currencicombo/orchestrator.env`, never in
the repo.
## Architecture on CT 8604
```
┌────────────────────┐
curucombo.曼李.com ──▶ NPMplus │192.168.11.167 │
(Cloudflare-proxied) │ TLS terminates here│
└─────────┬──────────┘
┌──────────────────────┴──────────────────────┐
│ │
▼ ▼
curucombo.曼李.com/* (default) curucombo.曼李.com/api/*
(incl. SSE /api/plans/*/events/stream)
│ │
CT 8604 │10.160.0.14:3000 CT 8604 │10.160.0.14:8080
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ currencicombo-webapp.service │ │ currencicombo-orchestrator │
│ nginx → /opt/currencicombo/ │ │ .service (systemd) │
│ webapp/dist/ │ │ node dist/index.js │
└─────────────────────────────┘ │ env /etc/currencicombo/ │
│ orchestrator.env │
└──────────────┬──────────────┘
postgresql + redis (same CT, local)
```
## Files
| path | purpose |
|---|---|
| `systemd/currencicombo-orchestrator.service` | Node orchestrator, reads `/etc/currencicombo/orchestrator.env` |
| `systemd/currencicombo-webapp.service` | nginx serving the Vite SPA on `:3000` |
| `webapp-nginx.conf` | full nginx.conf for the webapp unit |
| `.env.prod.example` | env template installed to `/etc/currencicombo/orchestrator.env` |
| `install.sh` | one-shot host setup: user / dirs / DB role / systemd units / first-run key handoff file |
| `install-prune-cron.sh` | opt-in daily cron that prunes `/var/lib/currencicombo/backups/` (30-day retention, keep-min 5) |
| `deploy-currencicombo-8604.sh` | build-and-swap deploy driver (the script Phoenix/proxmox deploy-api calls) |
| `README.md` | you're reading it |
## First-time setup on CT 8604
All commands run as **root** inside the CT.
1. Ensure Postgres + Redis are installed and running:
```
apt-get install -y postgresql redis-server
systemctl enable --now postgresql redis-server
```
2. Clone the repo into its staging location (once):
```
install -d -o root -g root /var/lib/currencicombo
git clone https://gitea.d-bis.org/d-bis/CurrenciCombo.git /var/lib/currencicombo/repo
```
3. Run `install.sh` (creates user, DB, systemd units, env file):
```
bash /var/lib/currencicombo/repo/scripts/deployment/install.sh
```
On success you'll see:
```
[install] generated EVENT_SIGNING_SECRET (64 hex)
[install] generated 3 API keys (initiator/settler/auditor)
[install] initial secrets written to /root/currencicombo-first-keys.txt (0600) — record in password manager, then 'shred -u /root/currencicombo-first-keys.txt'
[install] install complete.
```
`install.sh` writes the three API keys + `EVENT_SIGNING_SECRET` to **two** places:
- `/etc/currencicombo/orchestrator.env` — canonical, read by systemd (`0640`, owned by `currencicombo`).
- `/root/currencicombo-first-keys.txt` — **root-only handoff file** (`0600`). Grab it once, record the values in your password manager, then `shred -u` it.
The handoff file is **not** regenerated on re-run — if `orchestrator.env` already exists, `install.sh` does not produce new secrets.
4. (Optional) Install the backup-pruning cron:
```
bash /var/lib/currencicombo/repo/scripts/deployment/install-prune-cron.sh
```
Drops a `/etc/cron.daily/currencicombo-prune-backups` that deletes anything under `/var/lib/currencicombo/backups/` older than 30 days while **always keeping the newest 5** regardless of age. Safe on re-run; opt out with `sudo rm /etc/cron.daily/currencicombo-prune-backups`.
5. If you need to resolve any `EXT-*` blocker (e.g. point at a real dbis_core), edit `/etc/currencicombo/orchestrator.env` before the first deploy.
6. First build-and-start:
```
bash /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh
```
Expected tail:
```
[deploy] orchestrator ready: {"ready":true}
[deploy] portal OK (HTTP 200)
[deploy] EXT-* blocker summary from orchestrator boot log:
[ExternalBlockers] 6 active, 1 resolved
id: EXT-DBIS-CORE
id: EXT-CC-PAYMENT-ADAPTERS
...
id: EXT-CHAIN138-CI-RPC (resolved)
[deploy] deploy complete. ref=main sha=<short> ts=<timestamp>
```
## NPMplus ingress changes required at cutover
`curucombo.曼李.com` today proxies 100% to `10.160.0.14:3000`. After
cutover it must become a **single-origin path-routed proxy** with **two**
rules (the SSE endpoint lives at `/api/plans/:id/events/stream`, so it's
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. |
| `/` | `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`
intentionally returns `HTTP 421` for that path — a clean "upstream is
misconfigured" signal instead of silently returning `index.html` and
breaking the browser with a JSON parse error.
## Subsequent deploys
Every deploy after the first is just:
```
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh
```
Flags:
- `--ref=<branch-or-sha>` — deploy something other than `main`.
- `--dry-run` — print what would happen, don't touch anything.
- `--skip-migrate` — hotfix deploys that don't change the schema.
- `--skip-build` — reuse the build from the previous run (debugging only).
- `--rollback` — restore the most recent `/var/lib/currencicombo/backups/<ts>/` and restart units. Does **not** git-pull or rebuild.
Every deploy writes a timestamped backup to
`/var/lib/currencicombo/backups/<YYYYmmdd-HHMMSS>/` before swapping. Pruning is opt-in via `install-prune-cron.sh` (30-day retention, keep-min 5). Without the cron, backups accumulate forever — quietly filling `/var/lib` is how the next outage starts.
## Failure handling on deploy
**Rollback is manual.** `deploy-currencicombo-8604.sh` **does not** auto-restore the previous backup if the orchestrator fails to become ready. First cutovers typically fail because of env typos or migration mistakes, and auto-restoring hides the failure state ops needs.
Instead, on a readiness timeout the deploy script prints:
- last 40 lines of `journalctl -u currencicombo-orchestrator`
- last 20 lines of `journalctl -u currencicombo-webapp`
- **the exact `--rollback` command with the specific backup path filled in**
Example tail on failure:
```
================================================================
DEPLOY FAILED: orchestrator did not become ready after 60s
================================================================
## currencicombo-orchestrator (last 40 lines):
... env validation error: EVENT_SIGNING_SECRET is required ...
## Units are in whatever state deploy left them. To restore
## the previous build (does NOT revert DB migrations):
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh --rollback
# (will restore /var/lib/currencicombo/backups/20260423-140215)
================================================================
```
Rollback one-liner (when ops has decided to restore):
```
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh --rollback
```
Rollback restores the most recent backup and restarts both units. It **does not** touch the DB. If the failed deploy applied a new migration, DB rollback is a manual `psql` task — the orchestrator's migration runner only emits `up()` paths.
## Post-cutover smoke checks through NPMplus
Once the NPMplus `/api/*` rule is live, from a workstation (not the CT):
```
# 1. Front-door TLS is healthy
curl -skI https://curucombo.xn--vov0g.com/ | head -3
# expect: HTTP/2 200
# expect: NO 'x-nextjs-prerender' header (that was the old Next.js build)
# 2. SPA is the new Vite portal
curl -sk https://curucombo.xn--vov0g.com/ | grep -oE '<title>[^<]+</title>'
# expect: <title>Solace Bank Group PLC — Treasury Management Portal</title>
# 3. Orchestrator ready through NPMplus
curl -sk https://curucombo.xn--vov0g.com/api/ready | head -1
# expect: {"ready":true} (not HTML)
# 4. Orchestrator blocker log (through CT shell, not NPMplus)
ssh root@10.160.0.14 'journalctl -u currencicombo-orchestrator -n 200 | grep -E "ExternalBlockers|EXT-"'
# expect: [ExternalBlockers] 6 active, 1 resolved
# expect: one line per EXT-* id
# 5. SSE actually streams (catches silent NPMplus proxy_buffering=on misconfig)
curl -sk -N --max-time 5 -H 'Accept: text/event-stream' \
https://curucombo.xn--vov0g.com/api/plans/demo-pay-014/events/stream \
| head -20 || true
# expect: HTTP/2 200 with Content-Type: text/event-stream
# expect: at least one 'data: {...}\n\n' frame to arrive WITHIN ~1s
# if you see nothing for 3-5s and then everything dumps at once:
# NPMplus has proxy_buffering=on. Fix: proxy_buffering off; proxy_http_version 1.1; proxy_set_header Connection "";
# if the ping is 401/403: expected — SSE is auth-gated; the point is to
# prove the request REACHED the orchestrator (content-type header +
# chunked response headers) rather than hitting the Vite SPA.
```
A plain `HTTP/2 200` with a `Content-Type: text/html` body on `/api/ready` means NPMplus is silently falling back to the `/` rule — the `/api/*` rule is missing or ordered wrong. The `webapp-nginx.conf` in this repo returns `HTTP 421` for `/api/*` to make that case obvious when debugging CT-locally, but at the NPMplus edge nginx serves whatever NPMplus routes to it.
## Troubleshooting
| symptom | cause / check |
|---|---|
| `/api/*` returns `421 NPMplus is misconfigured` | NPMplus `/api/*` rule missing or wrong upstream. |
| `/events/*` connects then disconnects after ~60s | NPMplus forgot `proxy_buffering off` + high `proxy_read_timeout`. |
| orchestrator unit enters `activating (auto-restart)` loop | `journalctl -u currencicombo-orchestrator -n 80` — usually a zod env-validation error. The boot-time assertion message names the missing/invalid var. |
| orchestrator boot log says `[ExternalBlockers] N active` where N > 6 | you added an `EXT-*` env var without also updating the central registry in `orchestrator/src/config/externalBlockers.ts`. |
| `/health` returns 503 but `/ready` is 200 | memory `critical` is a separate signal from readiness. Inspect CT memory; this happens on constrained builders and is not a deploy bug. |
| portal page loads but MetaMask login does nothing | the portal couldn't reach `/api/auth/*`. Walk back up the NPMplus rule chain. |
## Cutting over from the pre-existing Next.js build
Phoenix previously had an older Next.js "ISO-20022 Combo Flow" app in
`/opt/currencicombo/webapp`. The cutover sequence on CT 8604 is:
1. **Backup the old install** out-of-band:
```
tar czf /root/currencicombo-preRepo-$(date +%s).tgz /opt/currencicombo /etc/currencicombo 2>/dev/null || true
```
2. **Disable the pre-existing systemd units** (they're the same names but point at the old tree):
```
systemctl stop currencicombo-webapp currencicombo-orchestrator
systemctl disable currencicombo-webapp currencicombo-orchestrator
```
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.
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)
After this PR merges and the above cutover runs cleanly, the
`/home/intlc/projects/proxmox` repo needs a separate commit to:
- Update `phoenix-deploy-api/deploy-targets.json` to point at:
- repo: `d-bis/CurrenciCombo`
- branch: `main`
- target: `default`
- deploy entrypoint: `scripts/deployment/deploy-currencicombo-8604.sh`
- Remove any stale `/opt/currencicombo/webapp` Next.js references.
- Drop any description of `ignoreBuildErrors: true` in `webapp/next.config.ts` — the new webapp is Vite+tsc-strict, no build-error suppression.

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bash
# deploy-currencicombo-8604.sh — build-and-swap deploy for CurrenciCombo.
#
# Runs on a systemd host that has already had `install.sh` applied once.
# This is the script referenced by the Proxmox repo's
# `phoenix-deploy-api/deploy-targets.json` tuple
# (repo=d-bis/CurrenciCombo, branch=main, target=default).
#
# Steps (each idempotent, each can be --dry-run'd):
# 1. git clone/pull /var/lib/currencicombo/repo to the target ref.
# 2. Build orchestrator (npm ci + npm run build).
# 3. Build portal/webapp (npm ci + npm run build), baking
# VITE_ORCHESTRATOR_URL into the bundle.
# 4. Run DB migrations (npm run migrate in orchestrator/).
# 5. Stop systemd units.
# 6. rsync build output into /opt/currencicombo/{orchestrator,webapp}.
# 7. Start systemd units.
# 8. Smoke-test /ready + portal / + print EXT-* blocker summary.
#
# Rollback: `--rollback` restores the previous backup under
# /var/lib/currencicombo/backups/<timestamp>.
#
# CT 8604 is in the filename for ops-grep-ability; the script itself is
# host-agnostic. Override paths via env vars if you run it elsewhere.
set -euo pipefail
# ----- defaults (override via env) ------------------------------------
: "${CC_GIT_REMOTE:=https://gitea.d-bis.org/d-bis/CurrenciCombo.git}"
: "${CC_GIT_REF:=main}"
: "${CC_REPO_DIR:=/var/lib/currencicombo/repo}"
: "${CC_APP_HOME:=/opt/currencicombo}"
: "${CC_BACKUP_DIR:=/var/lib/currencicombo/backups}"
: "${CC_USER:=currencicombo}"
# Portal build-time env. The NPMplus ingress path-routes /api/* and
# /events/* to the orchestrator, so same-origin works.
: "${VITE_ORCHESTRATOR_URL:=https://curucombo.xn--vov0g.com}"
: "${ORCHESTRATOR_UNIT:=currencicombo-orchestrator.service}"
: "${WEBAPP_UNIT:=currencicombo-webapp.service}"
: "${CC_HEALTH_URL:=http://127.0.0.1:8080/ready}"
: "${CC_PORTAL_URL:=http://127.0.0.1:3000/}"
: "${CC_HEALTH_TIMEOUT_SECS:=60}"
# ----- flags ----------------------------------------------------------
DRY_RUN=0
SKIP_MIGRATE=0
SKIP_BUILD=0
DO_ROLLBACK=0
usage() {
cat <<'USAGE'
Usage: sudo ./deploy-currencicombo-8604.sh [flags]
Flags:
--ref=<git-ref> Override CC_GIT_REF (default: main)
--dry-run Print commands, don't run them
--skip-migrate Skip `npm run migrate` step (use for hotfix
deploys where schema hasn't changed)
--skip-build Reuse the existing build in CC_REPO_DIR/dist
(useful after `--dry-run --skip-build=no` from
the previous run)
--rollback Restore the most recent backup and restart.
Does not run git/build/migrate.
-h, --help This help
Env overrides:
CC_GIT_REMOTE, CC_GIT_REF, CC_REPO_DIR, CC_APP_HOME, CC_BACKUP_DIR,
CC_USER, VITE_ORCHESTRATOR_URL, ORCHESTRATOR_UNIT, WEBAPP_UNIT,
CC_HEALTH_URL, CC_PORTAL_URL, CC_HEALTH_TIMEOUT_SECS
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--ref=*) CC_GIT_REF="${1#*=}"; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--skip-migrate) SKIP_MIGRATE=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--rollback) DO_ROLLBACK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
log() { printf '[deploy] %s\n' "$*" >&2; }
warn() { printf '[deploy][WARN] %s\n' "$*" >&2; }
die() { printf '[deploy][FATAL] %s\n' "$*" >&2; exit 1; }
run() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[deploy][dry-run] %s\n' "$*" >&2; else eval "$*"; fi; }
runcc() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[deploy][dry-run][as %s] %s\n' "${CC_USER}" "$*" >&2; else sudo -u "${CC_USER}" -H bash -lc "$*"; fi; }
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
# ----- rollback fast-path ---------------------------------------------
if [[ "${DO_ROLLBACK}" -eq 1 ]]; then
LATEST="$(ls -1dt "${CC_BACKUP_DIR}"/* 2>/dev/null | head -1 || true)"
[[ -n "${LATEST}" ]] || die "no backup under ${CC_BACKUP_DIR}"
log "rolling back to ${LATEST}"
run "systemctl stop '${WEBAPP_UNIT}' '${ORCHESTRATOR_UNIT}'"
run "rsync -a --delete '${LATEST}/orchestrator/' '${CC_APP_HOME}/orchestrator/'"
run "rsync -a --delete '${LATEST}/webapp/' '${CC_APP_HOME}/webapp/'"
run "systemctl start '${ORCHESTRATOR_UNIT}' '${WEBAPP_UNIT}'"
log "rollback applied. systemctl status ${ORCHESTRATOR_UNIT} to verify."
exit 0
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
log "cloning ${CC_GIT_REMOTE}${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
# ----- 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 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"
runcc "cd '${CC_REPO_DIR}' && VITE_ORCHESTRATOR_URL='${VITE_ORCHESTRATOR_URL}' npm run build"
else
log "skipping builds (--skip-build)"
fi
# ----- 3. migrations --------------------------------------------------
if [[ "${SKIP_MIGRATE}" -eq 0 ]]; then
log "running DB migrations"
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm run migrate"
else
log "skipping migrations (--skip-migrate)"
fi
# ----- 4. backup previous install ------------------------------------
TS="$(date +%Y%m%d-%H%M%S)"
BACKUP="${CC_BACKUP_DIR}/${TS}"
if [[ -d "${CC_APP_HOME}/orchestrator/dist" || -d "${CC_APP_HOME}/webapp/dist" ]]; then
log "backing up current install → ${BACKUP}"
run "install -d -o root -g root -m 0700 '${BACKUP}/orchestrator' '${BACKUP}/webapp'"
run "rsync -a '${CC_APP_HOME}/orchestrator/' '${BACKUP}/orchestrator/'"
run "rsync -a '${CC_APP_HOME}/webapp/' '${BACKUP}/webapp/'"
fi
# ----- 5. stop units --------------------------------------------------
log "stopping systemd units"
run "systemctl stop '${WEBAPP_UNIT}' || true"
run "systemctl stop '${ORCHESTRATOR_UNIT}' || true"
# ----- 6. swap in new build ------------------------------------------
log "rsyncing new build into ${CC_APP_HOME}"
# Orchestrator: dist/ + node_modules/ + package.json + package-lock.json
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"
# Webapp: dist/
runcc "rsync -a --delete '${CC_REPO_DIR}/dist/' '${CC_APP_HOME}/webapp/dist/'"
# ----- 7. start units ------------------------------------------------
log "starting systemd units"
run "systemctl start '${ORCHESTRATOR_UNIT}'"
run "systemctl start '${WEBAPP_UNIT}'"
# ----- 8. smoke -------------------------------------------------------
if [[ "${DRY_RUN}" -eq 1 ]]; then
log "dry-run: skipping smoke test"
exit 0
fi
log "waiting up to ${CC_HEALTH_TIMEOUT_SECS}s for orchestrator ${CC_HEALTH_URL}"
SECS=0
until curl -sfL --max-time 3 "${CC_HEALTH_URL}" >/dev/null 2>&1; do
SECS=$((SECS + 2))
if [[ "${SECS}" -ge "${CC_HEALTH_TIMEOUT_SECS}" ]]; then
# Loud failure summary. Deliberately does NOT auto-rollback — first
# cutovers often fail because of env/migration mistakes, and
# auto-restoring the old build hides the failure state ops needs to
# diagnose. Print the exact --rollback command with the specific
# backup path filled in, so it's one copy-paste away if desired.
{
echo
echo "================================================================"
echo "DEPLOY FAILED: orchestrator did not become ready after ${CC_HEALTH_TIMEOUT_SECS}s"
echo "================================================================"
echo
echo "## currencicombo-orchestrator (last 40 lines):"
journalctl -u "${ORCHESTRATOR_UNIT}" -n 40 --no-pager 2>&1 || echo "(journalctl unavailable)"
echo
echo "## currencicombo-webapp (last 20 lines):"
journalctl -u "${WEBAPP_UNIT}" -n 20 --no-pager 2>&1 || echo "(journalctl unavailable)"
echo
echo "## Units are in whatever state deploy left them. To restore"
echo "## the previous build (does NOT revert DB migrations):"
echo
if [[ -n "${BACKUP:-}" && -d "${BACKUP}" ]]; then
echo " sudo $0 --rollback"
echo " # (will restore ${BACKUP})"
else
echo " # No backup was taken (first deploy). Manual recovery required."
fi
echo
echo "================================================================"
} >&2
exit 1
fi
sleep 2
done
log "orchestrator ready: $(curl -sf "${CC_HEALTH_URL}")"
log "probing portal ${CC_PORTAL_URL}"
PORTAL_CODE="$(curl -s -o /dev/null -w '%{http_code}' "${CC_PORTAL_URL}" || echo ERR)"
[[ "${PORTAL_CODE}" =~ ^2 ]] || die "portal returned HTTP ${PORTAL_CODE}"
log "portal OK (HTTP ${PORTAL_CODE})"
log "EXT-* blocker summary from orchestrator boot log:"
journalctl -u "${ORCHESTRATOR_UNIT}" --no-pager -n 200 \
| grep -E 'ExternalBlockers|EXT-[A-Z0-9-]+' | tail -20 || true
log "deploy complete. ref=${CC_GIT_REF} sha=${REF_SHA} ts=${TS}"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# install-prune-cron.sh — opt-in cron job to prune old deploy backups.
#
# Run ONCE as root (or with sudo) after install.sh to enable daily
# pruning of /var/lib/currencicombo/backups/. The pruner:
# - deletes entries older than 30 days
# - ALWAYS keeps the newest N backups regardless of age (default 5)
#
# No-op on re-run. Opt out by removing /etc/cron.daily/currencicombo-prune-backups.
set -euo pipefail
BACKUP_DIR="${CC_BACKUP_DIR:-/var/lib/currencicombo/backups}"
RETAIN_DAYS="${CC_BACKUP_RETAIN_DAYS:-30}"
KEEP_MIN="${CC_BACKUP_KEEP_MIN:-5}"
CRON_FILE="/etc/cron.daily/currencicombo-prune-backups"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
-h|--help)
cat <<'USAGE'
Usage: sudo ./install-prune-cron.sh [--dry-run]
Env overrides:
CC_BACKUP_DIR (default: /var/lib/currencicombo/backups)
CC_BACKUP_RETAIN_DAYS (default: 30)
CC_BACKUP_KEEP_MIN (default: 5)
USAGE
exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
log() { printf '[install-prune-cron] %s\n' "$*" >&2; }
die() { printf '[install-prune-cron][FATAL] %s\n' "$*" >&2; exit 1; }
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
# The pruner script body. Runs daily via cron.daily.
# KEEP_MIN is enforced by listing backups newest-first, skipping the
# first KEEP_MIN, then deleting any remaining entries older than
# RETAIN_DAYS. This means we always keep at least KEEP_MIN (even if
# they're all <30 days old), and never delete one of the newest
# KEEP_MIN (even if it's >30 days old on a dormant host).
read -r -d '' PRUNER_BODY <<PRUNER || true
#!/usr/bin/env bash
# Managed by scripts/deployment/install-prune-cron.sh. Edits overwritten
# on next install. Opt out by deleting this file.
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR}"
RETAIN_DAYS=${RETAIN_DAYS}
KEEP_MIN=${KEEP_MIN}
[[ -d "\$BACKUP_DIR" ]] || exit 0
cd "\$BACKUP_DIR"
mapfile -t all < <(find . -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null | sort -rn | awk '{print \$2}')
count=\${#all[@]}
if (( count <= KEEP_MIN )); then
logger -t currencicombo-prune "count=\$count <= KEEP_MIN=\$KEEP_MIN; nothing to prune"
exit 0
fi
cutoff=\$(date -d "\$RETAIN_DAYS days ago" +%s)
deleted=0
kept=0
for i in "\${!all[@]}"; do
p="\${all[\$i]}"
if (( i < KEEP_MIN )); then
kept=\$((kept + 1))
continue
fi
mtime=\$(stat -c %Y "\$p" 2>/dev/null || echo 0)
if (( mtime < cutoff )); then
rm -rf -- "\$p"
deleted=\$((deleted + 1))
else
kept=\$((kept + 1))
fi
done
logger -t currencicombo-prune "deleted=\$deleted kept=\$kept total_before=\$count"
PRUNER
if [[ "${DRY_RUN}" -eq 1 ]]; then
log "[dry-run] would write ${CRON_FILE} (0755) with pruner targeting ${BACKUP_DIR}, retain ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}"
echo "---"
echo "${PRUNER_BODY}"
echo "---"
exit 0
fi
printf '%s\n' "${PRUNER_BODY}" > "${CRON_FILE}"
chmod 0755 "${CRON_FILE}"
chown root:root "${CRON_FILE}"
log "installed ${CRON_FILE} (backups older than ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}, target ${BACKUP_DIR})"
log "runs daily via /etc/cron.daily/. Opt out: sudo rm ${CRON_FILE}"
log "logs to syslog (tag currencicombo-prune); journalctl -t currencicombo-prune"

252
scripts/deployment/install.sh Executable file
View File

@@ -0,0 +1,252 @@
#!/usr/bin/env bash
# install.sh — idempotent first-time setup for CurrenciCombo on a systemd host.
#
# Intended to run ONCE per host as root (or with sudo). Running it again is
# safe: it will skip already-present artifacts and warn on conflicts.
#
# What this does:
# 1. Creates the `currencicombo` system user and /opt/currencicombo tree.
# 2. Installs nginx (Debian/Ubuntu or Alpine) if not present.
# 3. Ensures a local Postgres is running and creates a fresh
# `currencicombo` role + DB (refuses to touch an existing one unless
# --force-recreate is passed).
# 4. Ensures a local Redis is running.
# 5. Writes /etc/currencicombo/orchestrator.env from .env.prod.example,
# auto-populating EVENT_SIGNING_SECRET and ORCHESTRATOR_API_KEYS with
# fresh randoms the first time.
# 6. Installs /etc/currencicombo/webapp-nginx.conf.
# 7. Installs the two systemd units and runs `systemctl daemon-reload`.
# 8. Enables (does NOT start) both units. First start happens via
# scripts/deployment/deploy-currencicombo-8604.sh after the first
# successful build.
#
# This script is target-agnostic. It has no hardcoded IP / hostname /
# VLAN. The NPMplus ingress in front of it is configured separately —
# see scripts/deployment/README.md.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_USER="currencicombo"
APP_HOME="/opt/currencicombo"
ETC_DIR="/etc/currencicombo"
LOG_DIR="/var/log/currencicombo"
REPO_DIR="/var/lib/currencicombo/repo"
ENV_FILE="${ETC_DIR}/orchestrator.env"
NGINX_FILE="${ETC_DIR}/webapp-nginx.conf"
SYSTEMD_DIR="/etc/systemd/system"
FORCE_RECREATE_DB=0
DRY_RUN=0
SKIP_NGINX_INSTALL=0
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'
Usage: sudo ./install.sh [--force-recreate-db] [--skip-nginx-install] [--dry-run]
--force-recreate-db DROP and recreate the currencicombo Postgres role
and DB even if they already exist. DESTRUCTIVE.
--skip-nginx-install Do not apt/apk install nginx (use if you already
have a custom nginx build in place).
--dry-run Print the commands that would run, don't run them.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--force-recreate-db) FORCE_RECREATE_DB=1; shift ;;
--skip-nginx-install) SKIP_NGINX_INSTALL=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
# ----------------------------------------------------------------------
# 1. User + tree
# ----------------------------------------------------------------------
if id "${APP_USER}" >/dev/null 2>&1; then
log "user ${APP_USER} already exists"
else
log "creating system user ${APP_USER}"
run useradd --system --home-dir "${APP_HOME}" --shell /usr/sbin/nologin --user-group "${APP_USER}"
fi
for d in "${APP_HOME}" "${APP_HOME}/orchestrator" "${APP_HOME}/webapp" \
"${APP_HOME}/webapp/dist" "${ETC_DIR}" "${LOG_DIR}" "${REPO_DIR}"; do
run install -d -o "${APP_USER}" -g "${APP_USER}" -m 0755 "$d"
done
run chown "${APP_USER}:${APP_USER}" "${APP_HOME}" "${LOG_DIR}" "${REPO_DIR}"
run chmod 0750 "${ETC_DIR}"
# ----------------------------------------------------------------------
# 2. nginx (required by currencicombo-webapp.service)
# ----------------------------------------------------------------------
if [[ "${SKIP_NGINX_INSTALL}" -eq 0 ]]; then
if command -v nginx >/dev/null 2>&1; then
log "nginx already installed ($(nginx -v 2>&1 | head -1))"
elif command -v apt-get >/dev/null 2>&1; then
log "installing nginx via apt"
run 'DEBIAN_FRONTEND=noninteractive apt-get update -q'
run 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends nginx-light'
# We use our own nginx.conf via -c, so disable the distro site.
run systemctl disable --now nginx 2>/dev/null || true
elif command -v apk >/dev/null 2>&1; then
log "installing nginx via apk"
run apk add --no-cache nginx
run rc-update del nginx 2>/dev/null || true
else
die "no apt or apk available — install nginx manually or re-run with --skip-nginx-install"
fi
fi
[[ -f /etc/nginx/mime.types ]] || warn "/etc/nginx/mime.types missing; webapp-nginx.conf may fail"
# ----------------------------------------------------------------------
# 3. Postgres role + DB
# ----------------------------------------------------------------------
if ! command -v psql >/dev/null 2>&1; then
die "psql not on PATH — install Postgres on this host (e.g. apt install postgresql) before running install.sh"
fi
# Use the OS `postgres` superuser for DDL.
pg_role_exists() {
sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${APP_USER}';" 2>/dev/null | grep -q 1
}
pg_db_exists() {
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${APP_USER}';" 2>/dev/null | grep -q 1
}
if pg_role_exists; then
if [[ "${FORCE_RECREATE_DB}" -eq 1 ]]; then
log "dropping existing role/DB (--force-recreate-db)"
run "sudo -u postgres psql -c 'DROP DATABASE IF EXISTS ${APP_USER};'"
run "sudo -u postgres psql -c 'DROP ROLE IF EXISTS ${APP_USER};'"
else
warn "Postgres role ${APP_USER} already exists — skipping role/DB creation. Re-run with --force-recreate-db to wipe."
fi
fi
if ! pg_role_exists; then
log "creating Postgres role ${APP_USER}"
run "sudo -u postgres psql -c \"CREATE ROLE ${APP_USER} LOGIN;\""
fi
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
# ----------------------------------------------------------------------
# 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"
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"
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
warn "redis not detected; orchestrator will fall back to in-process event bus. Install redis for multi-replica support."
fi
# ----------------------------------------------------------------------
# 5. orchestrator.env
# ----------------------------------------------------------------------
FIRST_KEYS_FILE="/root/currencicombo-first-keys.txt"
if [[ -f "${ENV_FILE}" ]]; then
log "${ENV_FILE} already exists — leaving alone (no new keys generated)"
else
log "writing ${ENV_FILE}"
install -o "${APP_USER}" -g "${APP_USER}" -m 0640 "${SCRIPT_DIR}/.env.prod.example" "${ENV_FILE}"
# Auto-fill the two REQUIRED secrets so first boot doesn't crash.
SECRET="$(openssl rand -hex 32)"
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}'"
# 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
# manager.
if [[ "${DRY_RUN}" -eq 0 ]]; then
umask 077
cat > "${FIRST_KEYS_FILE}" <<EOF
# CurrenciCombo first-deploy secrets — generated $(date -Iseconds) by install.sh
#
# This file contains the initial API keys and event-signing secret for the
# orchestrator. The canonical live values live in ${ENV_FILE} and are what
# systemd actually loads. This file is a root-only handoff copy — record
# these values in your password manager, then:
#
# shred -u ${FIRST_KEYS_FILE}
#
# Re-running install.sh does NOT regenerate these values if ${ENV_FILE}
# already exists. Losing both ${FIRST_KEYS_FILE} and ${ENV_FILE} means
# rotating all three API keys and the signing secret.
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}
EOF
chmod 0600 "${FIRST_KEYS_FILE}"
chown root:root "${FIRST_KEYS_FILE}"
else
log "[dry-run] would write ${FIRST_KEYS_FILE} (0600, root:root)"
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
# ----------------------------------------------------------------------
# 6. webapp-nginx.conf
# ----------------------------------------------------------------------
run install -o "${APP_USER}" -g "${APP_USER}" -m 0644 \
"${SCRIPT_DIR}/webapp-nginx.conf" "${NGINX_FILE}"
# ----------------------------------------------------------------------
# 7. systemd units
# ----------------------------------------------------------------------
run install -o root -g root -m 0644 \
"${SCRIPT_DIR}/systemd/currencicombo-orchestrator.service" \
"${SYSTEMD_DIR}/currencicombo-orchestrator.service"
run install -o root -g root -m 0644 \
"${SCRIPT_DIR}/systemd/currencicombo-webapp.service" \
"${SYSTEMD_DIR}/currencicombo-webapp.service"
run systemctl daemon-reload
# ----------------------------------------------------------------------
# 8. Enable (but do NOT start yet — no build exists)
# ----------------------------------------------------------------------
run systemctl enable currencicombo-orchestrator.service
run systemctl enable currencicombo-webapp.service
log "install complete."
log " next: run scripts/deployment/deploy-currencicombo-8604.sh as root to build + start."

View File

@@ -0,0 +1,34 @@
[Unit]
Description=CurrenciCombo orchestrator (Node)
Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo
After=network-online.target postgresql.service redis-server.service redis.service
Wants=network-online.target
[Service]
Type=simple
User=currencicombo
Group=currencicombo
WorkingDirectory=/opt/currencicombo/orchestrator
EnvironmentFile=/etc/currencicombo/orchestrator.env
ExecStart=/usr/bin/node /opt/currencicombo/orchestrator/dist/index.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
StandardOutput=journal
StandardError=journal
SyslogIdentifier=currencicombo-orchestrator
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/log/currencicombo
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,34 @@
[Unit]
Description=CurrenciCombo webapp (Vite SPA served by nginx)
Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
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;'
ExecReload=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -s reload
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=currencicombo-webapp
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/log/currencicombo /run/currencicombo-webapp
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,80 @@
# Self-contained nginx.conf for the CurrenciCombo Vite SPA.
# Invoked by the `currencicombo-webapp.service` systemd unit and installed
# to /etc/currencicombo/webapp-nginx.conf by scripts/deployment/install.sh.
#
# Listens on :3000 (NPMplus upstream). NPMplus path-routes /api/* to the
# orchestrator on :8080 (with SSE-friendly settings — see README.md);
# everything else lands here.
# This config does NOT proxy /api itself — that's intentional so a wrong
# NPMplus rule fails loudly instead of silently bypassing the orchestrator.
worker_processes auto;
error_log /var/log/currencicombo/webapp-nginx.error.log warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/currencicombo/webapp-nginx.access.log combined;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
server_tokens off;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Uploads/bodies: the portal is a static SPA, so any request with a body
# is almost certainly mis-routed. Cap tight.
client_max_body_size 1m;
server {
listen 3000 default_server;
listen [::]:3000 default_server;
server_name _;
root /opt/currencicombo/webapp/dist;
index index.html;
# Security headers are also set by NPMplus, but apply them here too
# so they survive a direct-to-CT curl for debugging.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Immutable asset bundles.
location /assets/ {
access_log off;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Deny sourcemaps in prod.
location ~ \.map$ {
access_log off;
deny all;
return 404;
}
# Guard-rail: if NPMplus fails to path-route /api/*, surface it as a
# clean 421 rather than serving index.html and confusing the browser
# with a JSON parse error. The SSE endpoint lives at
# /api/plans/:id/events/stream, which also sits under /api/, so one
# rule covers both.
location /api/ {
return 421 "NPMplus is misconfigured: /api/* must proxy to orchestrator :8080\n";
add_header Content-Type text/plain always;
}
# SPA fallback. Must come last.
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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