From 0387eb3fd8b2a9180e67c4db29907f45b15c5c9f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 24 Oct 2026 14:45:04 +0700 Subject: [PATCH] feat(web): clearing-ledger tokens, app shell and status system Slice 1 of the dashboard revamp (T1, apps/web only; no API or business logic change). - Rewrite globals.css as readable token, base, shell and component sections: colour, type scale (11-28px, nothing smaller), spacing, radius and motion tokens; flat ruled panes; reduced-motion turns transitions off. - Load IBM Plex Sans/Mono with next/font; add a skip link and a focusable main. - Mark the current section with aria-current via a small client NavLink; mobile nav becomes one scrolling row. - One status system (lib/status.ts): glyph + label + hue per tone. Settlement and payout SUCCEEDED render as settled / paid out so they never look like a capture; in-flight provider attempts read "processing - outcome unknown". Covered by node:test unit tests wired into the web test script. - Fix duplicate React keys in DataTable rows whose id is an element. - Ledger business types render as labels, not statuses; drop the decorative pulsing live dot; generic loading skeleton; global error loads tokens. - docs/ui-signature.md now owns the clearing-ledger conventions. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/global-error.tsx | 3 +- apps/web/app/globals.css | 426 ++++++++++++++++++++++++++- apps/web/app/layout.tsx | 6 +- apps/web/app/ledger/page.tsx | 4 +- apps/web/app/loading.tsx | 2 +- apps/web/app/payments/[id]/page.tsx | 2 +- apps/web/app/payouts/page.tsx | 2 +- apps/web/app/reconciliation/page.tsx | 2 +- apps/web/app/settlements/page.tsx | 2 +- apps/web/components/console.tsx | 11 +- apps/web/components/nav-link.tsx | 9 + apps/web/components/sidebar.tsx | 3 +- apps/web/lib/status.test.ts | 24 ++ apps/web/lib/status.ts | 26 ++ apps/web/package.json | 2 +- apps/web/tsconfig.json | 1 + docs/ui-signature.md | 61 +++- docs/verification.md | 2 +- 18 files changed, 561 insertions(+), 27 deletions(-) create mode 100644 apps/web/components/nav-link.tsx create mode 100644 apps/web/lib/status.test.ts create mode 100644 apps/web/lib/status.ts diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index bbf9020..f6ba8ca 100644 --- a/apps/web/app/global-error.tsx +++ b/apps/web/app/global-error.tsx @@ -1,2 +1,3 @@ 'use client'; -export default function GlobalError({reset}:{error:Error&{digest?:string};reset:()=>void}){return
system boundary failed500

The trace system could not start.

No financial state was inferred from this UI failure. Check the local API and try again.

} +import './globals.css'; +export default function GlobalError({reset}:{error:Error&{digest?:string};reset:()=>void}){return
system boundary failed500

The trace system could not start.

No financial state was inferred from this UI failure. Check the local API and try again.

} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7e61479..c5eab9d 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,7 +1,423 @@ /* - Signature brief — “transaction trace lab” - Split-tone clearing desk; ultraviolet trace hue; verified teal / held amber / failed coral. - Hairline evidence panes, tabular mono numerals, and a continuous causal TraceRail. + Clearing ledger — see docs/ui-signature.md. + Ruled hairlines, a fixed column grid, tabular numerals, and colour reserved for money state. */ -:root{--ink:oklch(18% .025 270);--ink-2:oklch(23% .028 270);--paper:oklch(97% .008 270);--surface:oklch(99% .004 270);--line:oklch(86% .018 270);--muted:oklch(49% .025 270);--trace:oklch(55% .24 292);--verified:oklch(55% .14 174);--held:oklch(68% .14 76);--failed:oklch(59% .19 28);--radius:6px;--sidebar:236px;--shadow:0 18px 55px oklch(18% .025 270/.07)} -*{box-sizing:border-box}html{background:var(--ink)}body{margin:0;color:var(--ink);background:var(--paper);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.5}a{color:inherit;text-decoration:none}button{font:inherit}.app-shell{display:grid;grid-template-columns:var(--sidebar) minmax(0,1fr);min-height:100svh}.sidebar{position:sticky;top:0;height:100svh;background:var(--ink);color:oklch(91% .015 270);padding:22px 16px 18px;display:flex;flex-direction:column;border-right:1px solid oklch(100% 0 0/.08)}.brand-link{padding:4px 7px 24px}.wordmark{display:flex;align-items:center;gap:11px;font-weight:650;letter-spacing:-.02em}.wordmark-mark{display:grid;place-items:center;width:32px;height:32px;border:1px solid oklch(70% .18 292/.75);color:oklch(78% .16 292);font:600 10px ui-monospace,monospace;letter-spacing:.12em;box-shadow:inset 0 0 0 3px var(--ink)}.wordmark small{display:block;margin-top:1px;color:oklch(67% .025 270);font:500 8px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.13em}.sidebar nav{display:flex;flex-direction:column;gap:24px}.nav-group{display:flex;flex-direction:column;gap:3px}.nav-group>span{padding:0 9px 6px;color:oklch(57% .025 270);font:600 9px ui-monospace,monospace;letter-spacing:.16em;text-transform:uppercase}.nav-group a{padding:7px 9px;border-left:1px solid transparent;color:oklch(80% .02 270);font-size:13px;transition:color .18s,border-color .18s,background .18s}.nav-group a:hover,.nav-group a:focus-visible{outline:none;color:white;border-left-color:var(--trace);background:oklch(100% 0 0/.045)}.sidebar footer{margin-top:auto;padding:14px 9px 0;border-top:1px solid oklch(100% 0 0/.09);font:500 10px ui-monospace,monospace;color:oklch(76% .025 270);display:grid;gap:4px}.sidebar footer small{color:oklch(53% .025 270)}.live-dot{display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:50%;background:var(--verified);box-shadow:0 0 0 3px oklch(55% .14 174/.13);animation:signal 2.4s ease-in-out infinite}main{min-width:0;padding:46px clamp(22px,4vw,64px) 70px;background-color:var(--paper);background-image:none;position:relative}main:before{content:"";position:absolute;left:20px;top:0;bottom:0;border-left:1px solid oklch(55% .24 292/.11);pointer-events:none}.page-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:26px;padding-bottom:21px;border-bottom:1px solid var(--line)}.page-heading h1{margin:3px 0 5px;font-family:Georgia,"Times New Roman",serif;font-size:clamp(28px,3.4vw,44px);font-weight:500;letter-spacing:-.035em;line-height:1.06}.page-heading p{max-width:720px;margin:0;color:var(--muted);font-size:14px}.micro-label{color:var(--trace);font:650 9px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.16em;text-transform:uppercase}.balance-strip,.fact-strip{display:grid;grid-template-columns:1.2fr 1fr 1fr;margin:0 0 24px;border:1px solid var(--line);background:var(--line);gap:1px;border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)}.balance-strip>div,.fact-strip>div{background:var(--surface);padding:18px 20px;display:flex;flex-direction:column;min-width:0}.balance-strip .balance-primary{box-shadow:inset 3px 0 0 var(--trace)}.balance-strip strong{margin:9px 0 1px;font:550 clamp(24px,3vw,34px) ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:-.06em;font-variant-numeric:tabular-nums}.balance-strip .text-value{font-family:inherit;font-size:22px;letter-spacing:-.03em}.balance-strip small{color:var(--muted);font-size:11px}.overview-grid,.detail-grid{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(340px,.9fr);gap:18px;margin-bottom:18px}.evidence-pane{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;margin-bottom:18px}.evidence-pane>header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;padding:15px 18px;border-bottom:1px solid var(--line);background:oklch(98% .008 270)}.evidence-pane>header h2{margin:3px 0 0;font-size:16px;letter-spacing:-.02em}.evidence-pane>header>a{color:var(--trace);font-size:12px}.pane-body{min-width:0}.trace-rail{list-style:none;margin:0;padding:6px 18px}.trace-rail li{position:relative;display:grid;grid-template-columns:34px minmax(0,1fr) auto;gap:12px;align-items:start;padding:17px 0}.trace-rail li:not(:last-child):after{content:"";position:absolute;left:15px;top:46px;bottom:-9px;width:1px;background:var(--line)}.trace-node{z-index:1;display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--line);border-radius:50%;background:var(--surface);font:600 9px ui-monospace,monospace;color:var(--muted)}.trace-done .trace-node{border-color:var(--verified);color:var(--verified)}.trace-active .trace-node{border-color:var(--trace);color:var(--trace);box-shadow:0 0 0 4px oklch(55% .24 292/.09)}.trace-failed .trace-node{border-color:var(--failed);color:var(--failed)}.trace-rail strong{font-size:13px}.trace-rail p{margin:2px 0 0;color:var(--muted);font-size:11px;word-break:break-word}.trace-rail time{color:var(--muted);font:500 9px ui-monospace,monospace;white-space:nowrap}.status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font:600 9px ui-monospace,monospace;letter-spacing:.06em}.status>span{width:5px;height:5px;border-radius:50%;background:currentColor}.status-verified{color:var(--verified)}.status-held{color:oklch(50% .13 76)}.status-failed{color:var(--failed)}.status-neutral{color:var(--muted)}.table-scroll{max-width:100%;overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;padding:9px 14px;color:var(--muted);font:650 8px ui-monospace,monospace;letter-spacing:.13em;text-transform:uppercase;border-bottom:1px solid var(--line);white-space:nowrap}td{padding:11px 14px;border-bottom:1px solid oklch(90% .012 270);vertical-align:top;white-space:nowrap}tbody tr:last-child td{border-bottom:0}tbody tr:hover td{background:oklch(96.5% .015 292)}.align-right{text-align:right}.numeric{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-variant-numeric:tabular-nums}.mono-link{color:var(--trace);font:600 11px ui-monospace,monospace}.cell-note{display:block;color:var(--muted);max-width:240px;overflow:hidden;text-overflow:ellipsis}.inline-empty{padding:24px 18px;margin:0;color:var(--muted);font-size:12px}.fact-strip{grid-template-columns:repeat(4,1fr)}.fact-strip span{color:var(--muted);font:600 8px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase}.fact-strip strong{margin-top:6px;font:550 18px ui-monospace,monospace;font-variant-numeric:tabular-nums}.connection-state,.error-screen{min-height:calc(100svh - 120px);display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:720px}.connection-state:before,.error-screen:before{content:"00 — SIGNAL";display:grid;place-items:center;width:90px;height:90px;border:1px dashed var(--trace);color:var(--trace);font:600 9px ui-monospace,monospace;margin-bottom:24px}.connection-state h1,.error-screen h1{font:500 clamp(34px,5vw,58px)/1.03 Georgia,serif;letter-spacing:-.04em;margin:7px 0 14px}.connection-state p,.error-screen p{color:var(--muted);max-width:600px}.connection-state code{margin-top:18px;padding:10px 12px;border-left:2px solid var(--trace);background:var(--surface);font-size:11px}.error-screen{margin:auto;align-items:center;text-align:center}.error-screen:before{display:none}.error-screen>strong{font:500 clamp(80px,15vw,160px)/1 Georgia,serif;color:oklch(35% .04 292)}.error-screen a,.error-screen button{margin-top:16px;border:1px solid var(--trace);background:transparent;color:var(--trace);padding:9px 14px;cursor:pointer}.skeleton-heading i,.skeleton-strip i,.skeleton-pane i{display:block;background:oklch(90% .012 270);border-radius:2px}.skeleton-heading i:first-child{width:100px;height:9px;margin-bottom:10px}.skeleton-heading i:last-child{width:310px;height:36px}.skeleton-strip i:nth-child(1){width:70px;height:8px}.skeleton-strip i:nth-child(2){width:130px;height:30px;margin:11px 0}.skeleton-strip i:nth-child(3){width:95px;height:8px}.skeleton-pane header i:first-child{width:80px;height:8px}.skeleton-pane header i:last-child{width:140px;height:17px;margin-top:7px}.skeleton-rails{padding:20px;display:grid;gap:18px}.skeleton-rails i{height:34px;width:100%}@keyframes signal{50%{opacity:.35;transform:scale(.82)}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}.live-dot{animation:none}}@media(max-width:980px){.overview-grid,.detail-grid{grid-template-columns:1fr}.balance-strip{grid-template-columns:1fr 1fr}.balance-strip>div:last-child{grid-column:1/-1}.fact-strip{grid-template-columns:1fr 1fr}}@media(max-width:720px){.app-shell{display:block}.sidebar{position:relative;height:auto;padding:14px 16px}.brand-link{padding:0}.sidebar nav{margin-top:14px;display:flex;overflow-x:auto;gap:18px}.nav-group{min-width:max-content;display:block}.nav-group>span{display:none}.nav-group a{display:inline-block;border-left:0;border-bottom:1px solid transparent;padding:7px}.sidebar footer{display:none}main{padding:30px 16px 50px}main:before{display:none}.page-heading{align-items:flex-start}.balance-strip,.fact-strip{grid-template-columns:1fr}.balance-strip>div:last-child{grid-column:auto}.trace-rail li{grid-template-columns:34px minmax(0,1fr)}.trace-rail time{grid-column:2}.page-heading h1{font-size:32px}} + +/* ---------- Tokens ---------- */ +:root { + /* Surfaces and ink */ + --paper: oklch(98.5% .004 250); + --surface: oklch(100% 0 0); + --surface-sunk: oklch(97% .006 250); + --ink: oklch(22% .02 260); + --ink-2: oklch(47% .02 260); + --rule: oklch(91% .008 260); + --rule-strong: oklch(84% .01 260); + --accent: oklch(47% .2 285); + --accent-tint: oklch(97% .015 285); + --focus: var(--accent); + + /* Navigation chrome */ + --chrome: oklch(20% .025 270); + --chrome-2: oklch(27% .03 270); + --chrome-ink: oklch(94% .01 270); + --chrome-ink-2: oklch(75% .02 270); + --chrome-accent: oklch(74% .16 290); + + /* Status tones: glyph + label + hue, never hue alone */ + --tone-authorized: oklch(48% .09 250); --tone-authorized-bg: oklch(96.5% .02 250); + --tone-captured: oklch(47% .15 258); --tone-captured-bg: oklch(96.5% .02 258); + --tone-settled: oklch(47% .1 185); --tone-settled-bg: oklch(96.5% .02 185); + --tone-paid: oklch(42% .11 150); --tone-paid-bg: oklch(96.5% .02 150); + --tone-refunded: oklch(48% .09 305); --tone-refunded-bg: oklch(96.5% .02 305); + --tone-pending: oklch(47% .01 260); --tone-pending-bg: var(--paper); + --tone-attention: oklch(50% .12 65); --tone-attention-bg: oklch(96.5% .02 65); + --tone-failed: oklch(50% .19 27); --tone-failed-bg: oklch(96.5% .02 27); + --tone-done: var(--ink); --tone-done-bg: var(--surface-sunk); + --tone-neutral: var(--ink-2); --tone-neutral-bg: transparent; + + /* Type */ + --font-sans: var(--font-plex-sans), ui-sans-serif, system-ui, "Segoe UI", sans-serif; + --font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular, Consolas, monospace; + --text-xs: 11px; + --text-sm: 12px; + --text-md: 13px; + --text-lg: 15px; + --text-xl: 20px; + --text-2xl: 28px; + + /* Space, shape, motion */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --radius: 2px; + --sidebar: 216px; + --dur-fast: 120ms; + --dur-base: 180ms; + --ease-out: cubic-bezier(.2, .7, .2, 1); +} + +/* ---------- Base ---------- */ +*, *::before, *::after { box-sizing: border-box; } +html { background: var(--chrome); } +body { + margin: 0; + color: var(--ink); + background: var(--paper); + font: 400 var(--text-md)/1.5 var(--font-sans); + -webkit-font-smoothing: antialiased; +} +a { color: inherit; text-decoration: none; } +button { font: inherit; } +h1, h2, h3 { margin: 0; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.numeric, .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.micro-label { + color: var(--ink-2); + font: 500 var(--text-xs)/1.4 var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; +} + +.skip-link { + position: absolute; + left: var(--space-3); + top: -100px; + z-index: 10; + padding: var(--space-2) var(--space-3); + background: var(--surface); + color: var(--accent); + border: 1px solid var(--accent); +} +.skip-link:focus { top: var(--space-3); } + +/* ---------- Shell ---------- */ +.app-shell { + display: grid; + grid-template-columns: var(--sidebar) minmax(0, 1fr); + min-height: 100svh; +} +main { + min-width: 0; + padding: var(--space-6) clamp(var(--space-4), 3vw, var(--space-7)) var(--space-7); + background: var(--paper); +} +main:focus { outline: none; } + +.sidebar { + position: sticky; + top: 0; + height: 100svh; + display: flex; + flex-direction: column; + padding: var(--space-4) var(--space-3); + background: var(--chrome); + color: var(--chrome-ink); +} +.sidebar :focus-visible { outline-color: var(--chrome-accent); } +.brand-link { display: block; padding: var(--space-1) var(--space-2) var(--space-5); } +.wordmark { display: flex; align-items: center; gap: var(--space-3); font-weight: 600; letter-spacing: -.01em; } +.wordmark-mark { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--chrome-accent); + color: var(--chrome-accent); + font: 500 var(--text-xs) var(--font-mono); + letter-spacing: .08em; +} +.wordmark small { + display: block; + color: var(--chrome-ink-2); + font: 400 var(--text-xs) var(--font-mono); +} +.sidebar nav { display: flex; flex-direction: column; gap: var(--space-5); } +.nav-group { display: flex; flex-direction: column; gap: 2px; } +.nav-group > span { + padding: 0 var(--space-2) var(--space-1); + color: var(--chrome-ink-2); + font: 500 var(--text-xs) var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; +} +.nav-group a { + padding: 6px var(--space-2); + border-radius: var(--radius); + color: var(--chrome-ink-2); + transition: color var(--dur-fast) var(--ease-out), background-color var(--dur-fast) var(--ease-out); +} +.nav-group a:hover { color: var(--chrome-ink); background: var(--chrome-2); } +.nav-group a[aria-current="page"] { + color: var(--chrome-ink); + background: var(--chrome-2); + box-shadow: inset 2px 0 0 var(--chrome-accent); +} +.sidebar footer { + margin-top: auto; + padding: var(--space-3) var(--space-2) 0; + border-top: 1px solid var(--chrome-2); + display: grid; + gap: 2px; + color: var(--chrome-ink-2); + font: 400 var(--text-xs) var(--font-mono); +} + +/* ---------- Page structure ---------- */ +.page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-5); + margin-bottom: var(--space-5); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--rule-strong); +} +.page-heading h1 { + margin: 2px 0 var(--space-1); + font-size: var(--text-xl); + font-weight: 600; + letter-spacing: -.01em; + line-height: 1.25; + overflow-wrap: anywhere; +} +.page-heading p { max-width: 80ch; margin: 0; color: var(--ink-2); } + +.evidence-pane { + margin-bottom: var(--space-5); + overflow: hidden; + background: var(--surface); + border: 1px solid var(--rule-strong); + border-radius: var(--radius); +} +.evidence-pane > header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--rule); + background: var(--surface-sunk); +} +.evidence-pane > header h2 { margin-top: 2px; font-size: var(--text-lg); font-weight: 600; letter-spacing: -.01em; } +.evidence-pane > header > a { color: var(--accent); font-size: var(--text-sm); } +.evidence-pane > header > a:hover { text-decoration: underline; } +.pane-body { min-width: 0; } + +.overview-grid, .detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(340px, .9fr); + gap: var(--space-4); +} + +/* Key figures */ +.balance-strip, .fact-strip { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 1px; + margin: 0 0 var(--space-5); + overflow: hidden; + background: var(--rule-strong); + border: 1px solid var(--rule-strong); + border-radius: var(--radius); +} +.fact-strip { grid-template-columns: repeat(4, 1fr); } +.balance-strip > div, .fact-strip > div { + display: flex; + flex-direction: column; + min-width: 0; + padding: var(--space-3) var(--space-4); + background: var(--surface); +} +.balance-strip .balance-primary { box-shadow: inset 2px 0 0 var(--accent); } +.balance-strip strong, .fact-strip strong { + margin: var(--space-1) 0 2px; + font: 500 var(--text-2xl)/1.15 var(--font-mono); + font-variant-numeric: tabular-nums; + letter-spacing: -.02em; +} +.fact-strip strong { font-size: var(--text-xl); } +.balance-strip .text-value { font: 600 var(--text-xl)/1.4 var(--font-sans); } +.balance-strip small { color: var(--ink-2); font-size: var(--text-sm); } +.fact-strip span { + color: var(--ink-2); + font: 500 var(--text-xs) var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; +} + +/* ---------- Status ---------- */ +.status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--tone); + background: var(--tone-bg); + font: 500 var(--text-xs)/18px var(--font-sans); + white-space: nowrap; +} +.status > span { font-family: var(--font-mono); } +.tone-authorized { --tone: var(--tone-authorized); --tone-bg: var(--tone-authorized-bg); } +.tone-captured { --tone: var(--tone-captured); --tone-bg: var(--tone-captured-bg); } +.tone-settled { --tone: var(--tone-settled); --tone-bg: var(--tone-settled-bg); } +.tone-paid { --tone: var(--tone-paid); --tone-bg: var(--tone-paid-bg); } +.tone-refunded { --tone: var(--tone-refunded); --tone-bg: var(--tone-refunded-bg); } +.tone-pending { --tone: var(--tone-pending); --tone-bg: var(--tone-pending-bg); border: 1px dashed var(--rule-strong); } +.tone-attention { --tone: var(--tone-attention); --tone-bg: var(--tone-attention-bg); } +.tone-failed { --tone: var(--tone-failed); --tone-bg: var(--tone-failed-bg); } +.tone-done { --tone: var(--tone-done); --tone-bg: var(--tone-done-bg); } +.tone-neutral { --tone: var(--tone-neutral); --tone-bg: var(--tone-neutral-bg); } + +/* ---------- Tables ---------- */ +.table-scroll { max-width: 100%; overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--rule-strong); + color: var(--ink-2); + font-size: var(--text-xs); + font-weight: 500; + text-align: left; + white-space: nowrap; +} +th::first-letter { text-transform: uppercase; } +td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--rule); + vertical-align: top; + white-space: nowrap; +} +tbody tr:last-child td { border-bottom: 0; } +tbody tr:hover td { background: var(--accent-tint); } +.align-right { text-align: right; } +.mono-link { color: var(--accent); font: 500 var(--text-sm) var(--font-mono); } +.mono-link:hover { text-decoration: underline; } +.cell-note { display: block; max-width: 240px; overflow: hidden; color: var(--ink-2); text-overflow: ellipsis; } +.inline-empty { margin: 0; padding: var(--space-5) var(--space-4); color: var(--ink-2); } + +/* ---------- Trace rail ---------- */ +.trace-rail { margin: 0; padding: var(--space-1) var(--space-4); list-style: none; } +.trace-rail li { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: var(--space-3); + align-items: start; + padding: var(--space-3) 0; +} +.trace-rail li:not(:last-child)::after { + content: ""; + position: absolute; + left: 13px; + top: 40px; + bottom: -6px; + width: 1px; + background: var(--rule-strong); +} +.trace-node { + z-index: 1; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--rule-strong); + border-radius: 50%; + background: var(--surface); + color: var(--ink-2); + font: 500 var(--text-xs) var(--font-mono); +} +.trace-done .trace-node { border-color: var(--ink); color: var(--ink); } +.trace-active .trace-node { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint); } +.trace-failed .trace-node { border-color: var(--tone-failed); color: var(--tone-failed); } +.trace-rail strong { font-weight: 600; } +.trace-rail p { margin: 2px 0 0; color: var(--ink-2); font-size: var(--text-sm); overflow-wrap: anywhere; } +.trace-rail time { color: var(--ink-2); font: 400 var(--text-xs) var(--font-mono); white-space: nowrap; } + +/* ---------- Empty, offline and error states ---------- */ +.empty-trace { padding: var(--space-6) var(--space-4); } +.empty-coordinate { color: var(--ink-2); font: 500 var(--text-xs) var(--font-mono); } +.empty-trace h3 { margin: var(--space-2) 0 var(--space-1); font-size: var(--text-lg); font-weight: 600; } +.empty-trace p { margin: 0; color: var(--ink-2); } + +.connection-state, .error-screen { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + min-height: calc(100svh - 160px); + max-width: 640px; +} +.connection-state h1, .error-screen h1 { + margin: var(--space-2) 0 var(--space-3); + font-size: var(--text-2xl); + font-weight: 600; + letter-spacing: -.02em; + line-height: 1.2; +} +.connection-state p, .error-screen p { margin: 0; color: var(--ink-2); } +.connection-state code { + margin-top: var(--space-4); + padding: var(--space-2) var(--space-3); + border-left: 2px solid var(--accent); + background: var(--surface); + font: 400 var(--text-sm) var(--font-mono); +} +.error-screen > strong { color: var(--ink-2); font: 500 64px/1 var(--font-mono); letter-spacing: -.04em; } +.error-screen a, .error-screen button { + margin-top: var(--space-5); + padding: var(--space-2) var(--space-4); + border: 1px solid var(--accent); + border-radius: var(--radius); + background: var(--surface); + color: var(--accent); + cursor: pointer; +} +.error-screen a:hover, .error-screen button:hover { background: var(--accent-tint); } + +/* ---------- Loading skeleton ---------- */ +.skeleton i { display: block; border-radius: var(--radius); background: var(--rule); } +.skeleton .page-heading { display: block; } +.skeleton .page-heading i:first-child { width: 120px; height: 11px; margin-bottom: var(--space-2); } +.skeleton .page-heading i:nth-child(2) { width: 240px; height: 24px; margin-bottom: var(--space-2); } +.skeleton .page-heading i:nth-child(3) { width: min(520px, 100%); height: 13px; } +.skeleton .evidence-pane > header i { width: 160px; height: 15px; } +.skeleton-rows { display: grid; gap: var(--space-3); padding: var(--space-4); } +.skeleton-rows i { height: 18px; } + +/* ---------- Motion ---------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: 0s !important; + animation-duration: 0s !important; + } +} + +/* ---------- Responsive ---------- */ +@media (max-width: 980px) { + .overview-grid, .detail-grid { grid-template-columns: 1fr; } + .balance-strip, .fact-strip { grid-template-columns: 1fr 1fr; } + .balance-strip > div:last-child { grid-column: 1 / -1; } +} +@media (max-width: 720px) { + .app-shell { display: block; } + .sidebar { position: static; height: auto; padding: var(--space-3) var(--space-4) 0; } + .brand-link { padding: 0 0 var(--space-2); } + .sidebar nav { flex-direction: row; gap: var(--space-1); margin: 0 calc(-1 * var(--space-4)); padding: 0 var(--space-4) var(--space-2); overflow-x: auto; scrollbar-width: none; } + .nav-group { flex-direction: row; gap: var(--space-1); } + .nav-group > span, .sidebar footer { display: none; } + .nav-group a { white-space: nowrap; } + main { padding: var(--space-5) var(--space-4) var(--space-7); } + .page-heading { flex-direction: column; align-items: flex-start; } + .balance-strip, .fact-strip { grid-template-columns: 1fr; } + .balance-strip > div:last-child { grid-column: auto; } + .trace-rail li { grid-template-columns: 28px minmax(0, 1fr); } + .trace-rail time { grid-column: 2; } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 0d11ff3..48d95c7 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,10 +1,14 @@ import type { Metadata } from 'next'; +import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google'; import type { ReactNode } from 'react'; import { Sidebar } from '@/components/sidebar'; import './globals.css'; +const sans = IBM_Plex_Sans({ subsets: ['latin'], weight: ['400', '500', '600'], variable: '--font-plex-sans' }); +const mono = IBM_Plex_Mono({ subsets: ['latin'], weight: ['400', '500'], variable: '--font-plex-mono' }); + export const metadata: Metadata = { title: { default: 'fintech-lab', template: '%s · fintech-lab' }, description: 'A local payment engineering laboratory for tracing money and state.' }; export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { - return
{children}
; + return Skip to content
{children}
; } diff --git a/apps/web/app/ledger/page.tsx b/apps/web/app/ledger/page.tsx index 182e70f..1332199 100644 --- a/apps/web/app/ledger/page.tsx +++ b/apps/web/app/ledger/page.tsx @@ -1,5 +1,5 @@ import { api, formatMoney, formatTime } from '@/lib/api'; -import { DataTable, EvidencePane, PageHeading, Status } from '@/components/console'; +import { DataTable, EvidencePane, PageHeading } from '@/components/console'; interface Entry { transactionId:string; businessType:string; businessId:string; description:string; currency:string; postedAt:string; accountCode:string; accountType:string; debit:string; credit:string } export const dynamic='force-dynamic'; -export default async function LedgerPage(){const entries=await api('/ledger/entries');return <>Posted journals are immutable. Every transaction must balance within one currency.({id:`${e.transactionId}-${i}`,business:<>{e.description},account:e.accountCode.replaceAll('_',' ').toLowerCase(),type:e.accountType.toLowerCase(),debit:Number(e.debit)?formatMoney(e.debit,e.currency):'—',credit:Number(e.credit)?formatMoney(e.credit,e.currency):'—',posted:formatTime(e.postedAt)}))}/>} +export default async function LedgerPage(){const entries=await api('/ledger/entries');return <>Posted journals are immutable. Every transaction must balance within one currency.({id:`${e.transactionId}-${i}`,business:<>{e.businessType.replaceAll('_',' ').toLowerCase()}{e.description},account:e.accountCode.replaceAll('_',' ').toLowerCase(),type:e.accountType.toLowerCase(),debit:Number(e.debit)?formatMoney(e.debit,e.currency):'—',credit:Number(e.credit)?formatMoney(e.credit,e.currency):'—',posted:formatTime(e.postedAt)}))}/>} diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx index 6228031..55dca2b 100644 --- a/apps/web/app/loading.tsx +++ b/apps/web/app/loading.tsx @@ -1 +1 @@ -export default function Loading(){return
{[0,1,2].map((item)=>
)}
{[0,1,2,3,4].map((item)=>)}
} +export default function Loading(){return
{[0,1,2,3,4,5].map((item)=>)}
} diff --git a/apps/web/app/payments/[id]/page.tsx b/apps/web/app/payments/[id]/page.tsx index b8d0545..711fab1 100644 --- a/apps/web/app/payments/[id]/page.tsx +++ b/apps/web/app/payments/[id]/page.tsx @@ -146,7 +146,7 @@ export default async function PaymentDetailPage({ rows={detail.attempts.map((attempt) => ({ id: attempt.id, kind: attempt.kind.toLowerCase(), - status: , + status: , amount: formatMoney(attempt.amount, attempt.currency), provider: attempt.providerTransactionId ?? "—", }))} diff --git a/apps/web/app/payouts/page.tsx b/apps/web/app/payouts/page.tsx index 3075599..3f562d6 100644 --- a/apps/web/app/payouts/page.tsx +++ b/apps/web/app/payouts/page.tsx @@ -1,3 +1,3 @@ import { api,formatMoney,formatTime } from '@/lib/api';import { DataTable,EvidencePane,PageHeading,Status } from '@/components/console'; interface Row{id:string;status:string;amount:string;currency:string;providerReference?:string;failureCode?:string;createdAt:string;completedAt?:string} -export const dynamic='force-dynamic';export default async function Page(){const rows=await api('/payouts');return <>Requests reserve merchant available funds under a PostgreSQL advisory lock before provider processing begins.({id:r.id.slice(0,8)+'…',status:,amount:formatMoney(r.amount,r.currency),provider:r.providerReference??r.failureCode??'—',created:formatTime(r.createdAt)}))}/>} +export const dynamic='force-dynamic';export default async function Page(){const rows=await api('/payouts');return <>Requests reserve merchant available funds under a PostgreSQL advisory lock before provider processing begins.({id:r.id.slice(0,8)+'…',status:,amount:formatMoney(r.amount,r.currency),provider:r.providerReference??r.failureCode??'—',created:formatTime(r.createdAt)}))}/>} diff --git a/apps/web/app/reconciliation/page.tsx b/apps/web/app/reconciliation/page.tsx index 5a296f1..9d48c86 100644 --- a/apps/web/app/reconciliation/page.tsx +++ b/apps/web/app/reconciliation/page.tsx @@ -1,3 +1,3 @@ import { api,formatTime } from '@/lib/api';import { DataTable,EvidencePane,PageHeading,PaymentLink,Status } from '@/components/console'; interface Row{id:string;paymentId?:string;providerTransactionId?:string;issueType:string;severity:string;status:string;createdAt:string} -export const dynamic='force-dynamic';export default async function Page(){const rows=await api('/reconciliation/issues');return <>Differences become reviewable issues. The engine never silently repairs operational state or financial history.({id:r.id,severity:,issue:r.issueType.replaceAll('_',' ').toLowerCase(),payment:r.paymentId?:'—',provider:r.providerTransactionId?.slice(0,16)??'—',status:,detected:formatTime(r.createdAt)}))}/>} +export const dynamic='force-dynamic';export default async function Page(){const rows=await api('/reconciliation/issues');return <>Differences become reviewable issues. The engine never silently repairs operational state or financial history.({id:r.id,severity:,issue:r.issueType.replaceAll('_',' ').toLowerCase(),payment:r.paymentId?:'—',provider:r.providerTransactionId?.slice(0,16)??'—',status:,detected:formatTime(r.createdAt)}))}/>} diff --git a/apps/web/app/settlements/page.tsx b/apps/web/app/settlements/page.tsx index 713d29b..1ed1e56 100644 --- a/apps/web/app/settlements/page.tsx +++ b/apps/web/app/settlements/page.tsx @@ -1,3 +1,3 @@ import { api,formatMoney,formatTime } from '@/lib/api';import { DataTable,EvidencePane,PageHeading,Status } from '@/components/console'; interface Row{id:string;status:string;currency:string;grossAmount:string;feeAmount:string;netAmount:string;availableOn:string;completedAt?:string} -export const dynamic='force-dynamic';export default async function Page(){const rows=await api('/settlements');return <>Capture creates pending money. Settlement converts the provider receivable and releases merchant availability.({id:r.id.slice(0,8)+'…',status:,gross:formatMoney(r.grossAmount,r.currency),fee:formatMoney(r.feeAmount,r.currency),net:formatMoney(r.netAmount,r.currency),available:formatTime(r.availableOn)}))}/>} +export const dynamic='force-dynamic';export default async function Page(){const rows=await api('/settlements');return <>Capture creates pending money. Settlement converts the provider receivable and releases merchant availability.({id:r.id.slice(0,8)+'…',status:,gross:formatMoney(r.grossAmount,r.currency),fee:formatMoney(r.feeAmount,r.currency),net:formatMoney(r.netAmount,r.currency),available:formatTime(r.availableOn)}))}/>} diff --git a/apps/web/components/console.tsx b/apps/web/components/console.tsx index e5067ad..89974aa 100644 --- a/apps/web/components/console.tsx +++ b/apps/web/components/console.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { formatMoney, formatTime } from '@/lib/api'; +import { describeStatus, glyphs, type StatusOf } from '@/lib/status'; export function Wordmark() { return FLfintech-labpayment trace system; @@ -10,9 +11,9 @@ export function EvidencePane({ label, title, action, children, className = '' }: return
{label}

{title}

{action}
{children}
; } -export function Status({ value }: { value: string }) { - const kind = /FAILED|DEAD|DECLINED|LOST/.test(value) ? 'failed' : /PENDING|PROCESSING|REQUIRES|DISPUTED|OPEN/.test(value) ? 'held' : /SUCCEEDED|CAPTURED|AUTHORIZED|PROCESSED|POSTED|RESOLVED|REFUNDED/.test(value) ? 'verified' : 'neutral'; - return {value.replaceAll('_', ' ').toLowerCase()}; +export function Status({ value, of }: { value: string; of?: StatusOf }) { + const { tone, label } = describeStatus(value, of); + return {glyphs[tone]}{label}; } export interface Balance { currency: string; pending: string; available: string } @@ -21,7 +22,7 @@ export function BalanceStrip({ balances }: { balances: Balance[] }) { return
available / {balance.currency}{formatMoney(balance.available, balance.currency)}eligible for payout
pending / {balance.currency}{formatMoney(balance.pending, balance.currency)}awaiting settlement
-
rail stateOperational inbox + outbox active
+
rail stateOperationalinbox + outbox active
; } @@ -45,7 +46,7 @@ export function ConnectionState() { export function DataTable({ columns, rows, empty }: { columns: Array<{ key: string; label: string; align?: 'right' }>; rows: Array>; empty: string }) { if (!rows.length) return

{empty}

; - return
{columns.map((column) => )}{rows.map((row, index) => {columns.map((column) => )})}
{column.label}
{row[column.key]}
; + return
{columns.map((column) => )}{rows.map((row, index) => {columns.map((column) => )})}
{column.label}
{row[column.key]}
; } export function PageHeading({ eyebrow, title, children, action }: { eyebrow: string; title: string; children: ReactNode; action?: ReactNode }) { diff --git a/apps/web/components/nav-link.tsx b/apps/web/components/nav-link.tsx new file mode 100644 index 0000000..ba24b1f --- /dev/null +++ b/apps/web/components/nav-link.tsx @@ -0,0 +1,9 @@ +'use client'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +export function NavLink({ href, label }: { href: string; label: string }) { + const pathname = usePathname(); + const current = href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(`${href}/`); + return {label}; +} diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx index 71b6192..c737f9b 100644 --- a/apps/web/components/sidebar.tsx +++ b/apps/web/components/sidebar.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import { Wordmark } from './console'; +import { NavLink } from './nav-link'; const groups = [ { label: 'flow', items: [['Overview', '/'], ['Payments', '/payments'], ['Ledger', '/ledger']] }, @@ -8,5 +9,5 @@ const groups = [ ]; export function Sidebar() { - return ; + return ; } diff --git a/apps/web/lib/status.test.ts b/apps/web/lib/status.test.ts new file mode 100644 index 0000000..0c0d011 --- /dev/null +++ b/apps/web/lib/status.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { describeStatus } from './status.ts'; + +test('captured, settled and paid out never share a tone or label', () => { + const captured = describeStatus('CAPTURED'); + const settled = describeStatus('SUCCEEDED', 'settlement'); + const paid = describeStatus('SUCCEEDED', 'payout'); + assert.deepEqual([captured, settled, paid], [ + { tone: 'captured', label: 'captured' }, + { tone: 'settled', label: 'settled' }, + { tone: 'paid', label: 'paid out' }, + ]); +}); + +test('in-flight provider outcomes stay unknown, never success or failure', () => { + assert.deepEqual(describeStatus('PROCESSING', 'attempt'), { tone: 'pending', label: 'processing · outcome unknown' }); + for (const value of ['PENDING', 'CAPTURE_PENDING', 'REQUIRES_AUTHORIZATION']) assert.equal(describeStatus(value).tone, 'pending'); +}); + +test('generic success is not a money state, and unknown values stay neutral', () => { + assert.equal(describeStatus('SUCCEEDED', 'attempt').tone, 'done'); + assert.equal(describeStatus('SOMETHING_NEW').tone, 'neutral'); +}); diff --git a/apps/web/lib/status.ts b/apps/web/lib/status.ts new file mode 100644 index 0000000..e00937e --- /dev/null +++ b/apps/web/lib/status.ts @@ -0,0 +1,26 @@ +export type Tone = 'authorized' | 'captured' | 'settled' | 'paid' | 'refunded' | 'pending' | 'attention' | 'failed' | 'done' | 'neutral'; +export type StatusOf = 'attempt' | 'refund' | 'settlement' | 'payout' | 'severity'; + +export const glyphs: Record = { authorized: '○', captured: '◐', settled: '●', paid: '■', refunded: '↺', pending: '…', attention: '▲', failed: '✕', done: '✓', neutral: '—' }; + +const tones: Record = { + AUTHORIZED: 'authorized', CAPTURED: 'captured', REFUNDED: 'refunded', PARTIALLY_REFUNDED: 'refunded', + CREATED: 'neutral', REQUIRES_AUTHORIZATION: 'pending', CAPTURE_PENDING: 'pending', PENDING: 'pending', PROCESSING: 'pending', IN_PROGRESS: 'pending', + DISPUTED: 'attention', OPEN: 'attention', RETRY: 'attention', ACCOUNTING_EXCEPTION: 'attention', + FAILED: 'failed', DEAD: 'failed', MERCHANT_LOST: 'failed', + SUCCEEDED: 'done', PROCESSED: 'done', POSTED: 'done', PUBLISHED: 'done', RESOLVED: 'done', CLOSED: 'done', MERCHANT_WON: 'done', +}; + +/** + * One status system for every page. `of` names the record when the raw value alone is ambiguous: + * SUCCEEDED means "settled" for a settlement and "paid out" for a payout, and those must never look like a capture. + */ +export function describeStatus(value: string, of?: StatusOf): { tone: Tone; label: string } { + const label = value.replaceAll('_', ' ').toLowerCase(); + if (value === 'SUCCEEDED' && of === 'settlement') return { tone: 'settled', label: 'settled' }; + if (value === 'SUCCEEDED' && of === 'payout') return { tone: 'paid', label: 'paid out' }; + if (value === 'SUCCEEDED' && of === 'refund') return { tone: 'refunded', label: 'refunded' }; + if (value === 'PROCESSING' && (of === 'attempt' || of === 'refund')) return { tone: 'pending', label: 'processing · outcome unknown' }; + if (of === 'severity') return { tone: value === 'HIGH' || value === 'CRITICAL' ? 'attention' : 'neutral', label }; + return { tone: tones[value] ?? 'neutral', label }; +} diff --git a/apps/web/package.json b/apps/web/package.json index cc507b0..607090e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "echo \"No automated browser tests configured. See docs/verification.md for manual smoke checks.\"" + "test": "node --test lib/*.test.ts" }, "dependencies": { "next": "^15.5.2", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8b1f373..c97b4b4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -6,6 +6,7 @@ "skipLibCheck": true, "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", diff --git a/docs/ui-signature.md b/docs/ui-signature.md index aa8ea45..7ae3109 100644 --- a/docs/ui-signature.md +++ b/docs/ui-signature.md @@ -1,9 +1,60 @@ -# UI signature: transaction trace lab +# UI signature: clearing ledger -Status: current. +Status: current. Owner: visual conventions of the `apps/web` inspection dashboard. Tokens live at the top of [`globals.css`](../apps/web/app/globals.css); components in [`console.tsx`](../apps/web/components/console.tsx). -The dashboard behaves like a forensic clearing desk: a pale cool work surface held inside deep-ink navigation chrome, with a single ultraviolet trace color following one payment through provider, inbox, ledger, settlement, and payout. Hairline coordinate rules and square-ish “evidence panes” replace floating cards. Status colors are limited to verified teal, held amber, and failed coral. +The dashboard reads like a bound ledger held inside deep-ink navigation chrome: ruled hairlines, square panes, a fixed column grid and numerals set like a bank statement. Colour is reserved for money state; everything else is ink on paper. The `TraceRail` keeps one payment's lifecycle on a single line, and `BalanceStrip` / `fact-strip` hold key figures in one divided row. -Numerals use a tabular monospace face. The signature visualization is the `TraceRail`: one continuous lifecycle line whose checkpoints expose the exact boundary crossed and timestamp. KPI values form one divided `BalanceStrip`. Empty states describe the next financial event that will appear; loading skeletons preserve the evidence-pane geometry. Motion is limited to the active checkpoint trace and is disabled under `prefers-reduced-motion`. +Users are engineers and operators looking for a record, its state and its amounts. Findability and legibility beat decoration. -This language is intentionally neither a banking-marketing page nor a generic analytics dashboard: its visual structure exists to teach event causality. +## Page structure + +Every page uses the same order: `PageHeading` (mono eyebrow, 20px title, one-line description), then key figures when the page has them, then one or more `EvidencePane`s holding tables or traces. The sidebar marks the current section with `aria-current="page"`; a skip link jumps to `main`. Below 720px the sidebar becomes a top bar with one horizontally scrolling row of links. + +## Type + +- IBM Plex Sans for interface text and IBM Plex Mono for amounts, identifiers, timestamps and eyebrow labels, loaded with `next/font/google` (self-hosted at build time; no runtime request to Google). +- Scale: 11 / 12 / 13 / 15 / 20 / 28px (`--text-xs` … `--text-2xl`). Body and table text is 13px. Nothing renders below 11px. +- Numerals use `font-variant-numeric: tabular-nums`; money columns align right. + +## Colour + +| Token | Use | +| --- | --- | +| `--paper`, `--surface`, `--surface-sunk` | Page, pane body, pane header | +| `--ink`, `--ink-2` | Primary and secondary text (secondary ≥ 6:1 on every surface) | +| `--rule`, `--rule-strong` | Row hairlines, pane borders | +| `--accent` | Links, focus ring, current trace step. Not used for status | +| `--chrome*` | Navigation rail | + +## Status system + +`Status` is the only way a state is rendered. Each tone has a glyph, a label and a hue, so meaning never depends on colour alone; the raw API value is the `title`. + +| Tone | Glyph | Values | +| --- | --- | --- | +| authorized | ○ | `AUTHORIZED` | +| captured | ◐ | `CAPTURED` | +| settled | ● | settlement `SUCCEEDED`, labelled "settled" | +| paid out | ■ | payout `SUCCEEDED`, labelled "paid out" | +| refunded | ↺ | `REFUNDED`, `PARTIALLY_REFUNDED`, refund `SUCCEEDED` | +| pending | … (dashed) | `PENDING`, `PROCESSING`, `REQUIRES_AUTHORIZATION`, `CAPTURE_PENDING`, `IN_PROGRESS`; provider attempt/refund `PROCESSING` reads "processing · outcome unknown" | +| attention | ▲ | `DISPUTED`, `OPEN`, `RETRY`, `ACCOUNTING_EXCEPTION`, `HIGH`/`CRITICAL` severity | +| failed | ✕ | `FAILED`, `DEAD`, `MERCHANT_LOST` | +| done | ✓ | Non-money success: attempt `SUCCEEDED`, `PROCESSED`, `POSTED`, `PUBLISHED`, `RESOLVED`, `CLOSED`, `MERCHANT_WON` | +| neutral | — | `CREATED`, `CANCELLED`, `IGNORED`, anything unmapped | + +Rules the UI must not blur: + +- Captured, settled and paid out are different tones and glyphs. A record type whose raw value is generic (`SUCCEEDED`) passes `of` so it renders as its money state. +- An unknown or in-flight provider outcome is pending, never success or failure. +- Business types (capture, refund, settlement) are labels, not statuses. + +## Density, shape and motion + +- Table cells pad 8px × 12px (about 34px rows); panes use 2px corners and 1px rules, no drop shadows. +- Spacing steps of 4px (`--space-1` … `--space-7`). +- Motion is 120–180ms ease-out and only explains change (hover, current item). `prefers-reduced-motion` turns all transitions and animations off. + +## States + +Empty states name the next financial event that will appear. The offline state lists the local commands to start the stack. Loading skeletons keep the heading and pane geometry. Error screens say the underlying record was not changed and how to retry. diff --git a/docs/verification.md b/docs/verification.md index 8f46115..3f0b278 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -83,7 +83,7 @@ Build precedes standalone typecheck on a fresh checkout because the tracked Next | PostgreSQL integration | Five financial concurrency scenarios plus 17 F02 sealing/function-context regressions using checked-in migrations | Every financial race or full lifecycle | | Seeded HTTP/browser smoke | Local API/UI render fixture evidence | Actual authorization/capture at a PSP | -The web `test` script prints a notice; no automated browser test suite is configured. +The web `test` script runs `node:test` unit tests for pure display logic (`apps/web/lib/*.test.ts`, using Node's built-in TypeScript stripping); no automated browser test suite is configured. ### Windows sandbox permissions