From ad866f087cd55fea1f40a9b888ac5b9ca4e44ec9 Mon Sep 17 00:00:00 2001 From: Coredevjay Date: Wed, 21 Oct 2026 10:45:19 +0000 Subject: [PATCH] feat(realtime): event bus, fidelity tiers, loading states and /status page Closes #303 Closes #304 Closes #309 Closes #317 - typed realtime event bus with ordering, isolation and snapshot replay - fidelity tiers with signal detection, hysteresis and topic diffs - RealtimeView skeleton/state matrix primitives - /status route (REST snapshot + realtime), linked from Navbar and footer - restore empty Navbar.tsx and truncated globals.css from before 7a01fc8 --- README.md | 1 + docs/realtime-fidelity.md | 34 ++ src/app/globals.css | 356 +++++++++++++++++++ src/app/status/page.tsx | 30 ++ src/components/layout/Footer.tsx | 1 + src/components/layout/Navbar.tsx | 152 ++++++++ src/components/status/ProtocolStatusView.tsx | 106 ++++++ src/components/ui/RealtimeSkeletons.tsx | 80 +++++ src/components/ui/index.ts | 1 + src/hooks/useRealtimeTopic.ts | 57 +++ src/lib/realtime/bus.ts | 74 ++++ src/lib/realtime/client.ts | 67 +++- src/lib/realtime/fidelity.ts | 237 ++++++++++++ src/lib/realtime/index.ts | 4 + src/lib/realtime/realtime.test.ts | 67 ++++ src/lib/realtime/status.ts | 13 + src/lib/realtime/topics.ts | 35 ++ 17 files changed, 1312 insertions(+), 3 deletions(-) create mode 100644 docs/realtime-fidelity.md create mode 100644 src/app/status/page.tsx create mode 100644 src/components/status/ProtocolStatusView.tsx create mode 100644 src/components/ui/RealtimeSkeletons.tsx create mode 100644 src/hooks/useRealtimeTopic.ts create mode 100644 src/lib/realtime/bus.ts create mode 100644 src/lib/realtime/fidelity.ts create mode 100644 src/lib/realtime/index.ts create mode 100644 src/lib/realtime/realtime.test.ts create mode 100644 src/lib/realtime/status.ts create mode 100644 src/lib/realtime/topics.ts diff --git a/README.md b/README.md index 780aba7..89f1983 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ this policy. | `/` | Landing — live oracle status, recent payouts, protocol stats | | `/cover` | Policy purchase flow | | `/provide` | LP capital provision + pool allocation | +| `/status` | Protocol status, maintenance windows and incidents (REST snapshot + realtime) | ## Quick start diff --git a/docs/realtime-fidelity.md b/docs/realtime-fidelity.md new file mode 100644 index 0000000..deda9c2 --- /dev/null +++ b/docs/realtime-fidelity.md @@ -0,0 +1,34 @@ +# Realtime runbook + +## Event bus (`src/lib/realtime/bus.ts`) +The client decodes inbound `{ topic, payload }` messages and publishes them to a module-scoped typed bus. Consumers (hooks, stores, analytics) subscribe to the bus via `useRealtimeTopic`, never to the socket. + +- Topic ids and payload types live in `topics.ts`. +- Snapshot topics replay their latest value to late subscribers (`getSnapshot`). +- Per-topic arrival order is preserved; re-entrant publishes are queued, not nested. +- A throwing handler is caught and logged (no payload) and never blocks other handlers. +- Max 100 handlers per topic; subscribing the same handler twice (StrictMode) is a no-op. + +## View status matrix (`RealtimeView`) +| status | shows | indicator | +|---|---|---| +| `empty` | skeleton (sized to final content) | — | +| `live` | data | Live | +| `disconnected` | last data | Offline — showing last values | +| `reconnecting` | last data | Reconnecting… | +| `polling` | data | Updating periodically (realtime off) | + +Skeletons render only for `empty`; a refresh of present data never re-shows them. Shimmer follows the global reduced-motion policy. + +## Fidelity tiers (`src/lib/realtime/fidelity.ts`) +| tier | topics | coalescing | transport | +|---|---|---|---| +| `full` | oracle_update, protocol_status, network_health | none | websocket | +| `lite` | oracle_update, protocol_status | oracle_update ≥ 5 s | websocket | +| `minimal` | protocol_status (via polling) | — | polling | + +Selection (`selectTier`): `saveData` / `2g` / battery < 15% (not charging) → `minimal`; `3g`, battery < 30%, ≤ 2 GB memory or ≤ 2 cores → `lite`; otherwise `full`. Missing signals assume unconstrained; SSR renders `full`. + +Hysteresis: a downgrade applies after 3 s of a stable candidate, an upgrade after 30 s. Tier changes are sent to the client as subscribe/unsubscribe deltas (`syncTopics`), and the full set is re-sent on every reconnect. Topics registered with `critical: true` in `useRealtimeTopic` are never dropped by a downgrade. + +Manual override: `fidelityController.setOverride("lite" | null)` (dev console / panel); the active tier is `fidelityController.getTier()`. diff --git a/src/app/globals.css b/src/app/globals.css index e69de29..2ddc5f6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -0,0 +1,356 @@ +@import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ── Refract Design Tokens ─────────────────────────────────────────────────── */ + --pm-text: 237 233 248; + --pm-muted: 123 111 168; + --pm-muted-2: 74 64 112; + + /* Alph + + /* Brand — violet */ + --pm-violet: 139 92 246; + --pm-violet-2: 124 58 237; + --pm-violet-dim: 139 92 246; + --pm-violet-glow: 139 92 246; + + /* Status */ + --pm-safe: 16 185 129; /* covered */ + --pm-risk: 245 158 11; /* at risk */ + --pm-danger: 239 68 68; /* triggered */ + + /* Text */ + --pm-text: 237 233 248; + --pm-muted: 123 111 168; + --pm-muted-2: 74 64 112; + + /* Alpha levels for tokens that are always used at a fixed opacity */ + --pm-border-a: 0.15; + --pm-border-2-a: 0.3; + --pm-violet-dim-a: 0.12; + --pm-safe-bg-a: 0.1; + --pm-risk-bg-a: 0.1; + --pm-danger-bg-a: 0.1; + + /* Radius */ + --pm-r: 8px; + --pm-r-lg: 16px; + --pm-r-xl: 24px; + + /* Motion tokens — named durations & easings (single source of truth). + All CSS animations/transitions should reference these instead of + scattering literal durations across files. */ + --pm-duration-fast: 0.15s; + --pm-duration-base: 0.2s; + --pm-duration-slow: 0.4s; + --pm-duration-slower: 0.5s; + --pm-duration-spin: 0.6s; + --pm-duration-pulse: 2s; + --pm-ease-standard: ease; + --pm-ease-out: ease-out; + --pm-ease-linear: linear; +} + +/* ── Base ────────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } +html, body { background: rgb(var(--pm-bg)); color: rgb(var(--pm-text)); } + +body { + font-family: "Inter", system-ui, sans-serif; + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +.font-display { font-family: "Syne", sans-serif; } +.font-mono { font-family: "JetBrains Mono", monospace; } + +/* ── Scrollbar ───────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: rgb(var(--pm-surface)); } +::-webkit-scrollbar-thumb { background: rgb(var(--pm-surface-3)); border-radius: 2px; } + +/* ── Layout utilities ────────────────────────────────────────────────────── */ +.pm-panel { + background: rgb(var(--pm-surface)); + border: 1px solid rgb(var(--pm-border) / var(--pm-border-a)); + border-radius: var(--pm-r-lg); +} + +.pm-divider { border: none; border-top: 1px solid rgb(var(--pm-border) / var(--pm-border-a)); } + +/* ── Status tags ─────────────────────────────────────────────────────────── */ +.pm-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 100px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; +} + +.pm-tag-safe { background: rgb(var(--pm-safe) / var(--pm-safe-bg-a)); color: rgb(var(--pm-safe)); } +.pm-tag-risk { background: rgb(var(--pm-risk) / var(--pm-risk-bg-a)); color: rgb(var(--pm-risk)); } +.pm-tag-danger { background: rgb(var(--pm-danger) / var(--pm-danger-bg-a)); color: rgb(var(--pm-danger)); } +.pm-tag-violet { background: rgb(var(--pm-violet) / var(--pm-violet-dim-a)); color: rgb(var(--pm-violet)); } + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.pm-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--pm-r); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + outline: none; + transition: all var(--pm-duration-base); + white-space: nowrap; +} + +.pm-btn-primary { + background: rgb(var(--pm-violet)); + color: white; +} +.pm-btn-primary:hover { background: rgb(var(--pm-violet-2)); transform: translateY(-1px); } + +.pm-btn-outline { + background: transparent; + color: rgb(var(--pm-violet)); + border: 1px solid rgb(var(--pm-border-2) / var(--pm-border-2-a)); +} +.pm-btn-outline:hover { background: rgb(var(--pm-violet) / var(--pm-violet-dim-a)); } + +.pm-btn-ghost { + background: transparent; + color: rgb(var(--pm-muted)); + border: 1px solid rgb(var(--pm-border) / var(--pm-border-a)); +} +.pm-btn-ghost:hover { color: rgb(var(--pm-text)); border-color: rgb(var(--pm-border-2) / var(--pm-border-2-a)); } + +/* ── Inputs ──────────────────────────────────────────────────────────────── */ +.pm-input { + width: 100%; + background: rgb(var(--pm-surface-2)); + border: 1px solid rgb(var(--pm-border) / var(--pm-border-a)); + border-radius: var(--pm-r); + color: rgb(var(--pm-text)); + padding: 10px 14px; + font-size: 14px; + outline: none; + transition: border-color var(--pm-duration-base); +} +.pm-input:focus { border-color: rgb(var(--pm-violet)); } +.pm-input::placeholder { color: rgb(var(--pm-muted-2)); } + +/* ── Slider ──────────────────────────────────────────────────────────────── */ +.pm-slider { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: linear-gradient( + to right, + rgb(var(--pm-violet)) var(--pct, 50%), + rgb(var(--pm-surface-3)) var(--pct, 50%) + ); + border-radius: 2px; + cursor: pointer; + outline: none; +} +.pm-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: rgb(var(--pm-violet)); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 0 4px rgb(var(--pm-violet) / 0.2); +} + +/* ── Risk heat gradient (signature element) ──────────────────────────────── */ +.risk-heat-low { background: linear-gradient(135deg, rgb(var(--pm-safe) / 0.08), transparent 70%); } +.risk-heat-medium { background: linear-gradient(135deg, rgb(var(--pm-risk) / 0.08), transparent 70%); } +.risk-heat-high { background: linear-gradient(135deg, rgb(var(--pm-danger) / 0.08), transparent 70%); } + +/* ── Motion ──────────────────────────────────────────────────────────────── + Documented motion policy for Refract. + + Decision (issue #37): framer-motion is NOT adopted. It was a declared + production dependency with zero imports, so it has been removed from + package.json and the README stack. All motion is expressed with the CSS + keyframes/transitions below, driven by the named tokens in :root. + + Reduced-motion policy (single documented rule): + - JS side: `usePrefersReducedMotion` (src/hooks/usePrefersReducedMotion.ts) + is the single source of truth for components that need to branch in JS. + - CSS side: the `@media (prefers-reduced-motion: reduce)` override at the + bottom of this file is the belt-and-braces fallback that neutralises + every animation/transition declared here. + No component may animate without going through one of these two paths. + Motion is never required to convey state (e.g. the Counter value is + readable instantly under reduced motion). + ─────────────────────────────────────────────────────────────────────────── */ + +/* ── Animations ──────────────────────────────────────────────────────────── */ +/* `fadeUp` is defined once here (issue #39); tailwind.config.ts references + * this keyframe via `animation.fade-up` instead of redeclaring it, so this + * file is the single source of truth for the animation. */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-fade-up { animation: fadeUp var(--pm-duration-slow) var(--pm-ease-standard) forwards; } + +@keyframes shieldPulse { + 0% { box-shadow: 0 0 0 0 rgb(var(--pm-violet) / 0.4); } + 70% { box-shadow: 0 0 0 16px rgb(var(--pm-violet) / 0); } + 100% { box-shadow: 0 0 0 0 rgb(var(--pm-violet) / 0); } +} +.pm-glow { animation: shieldPulse var(--pm-duration-pulse) infinite; } + +@keyframes spin { + to { transform: rotate(360deg); } +} +.pm-spin { animation: spin var(--pm-duration-spin) var(--pm-ease-linear) infinite; } + +/* pmShimmer supersedes the old `shimmer` keyframes that used to live in + tailwind.config.ts (issue #39) — one shimmer source only. */ +@keyframes pmShimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +.pm-shimmer { + background: linear-gradient( + 90deg, + var(--pm-surface-2) 25%, + var(--pm-surface-3) 50%, + var(--pm-surface-2) 75% + ); + background-size: 200% 100%; + animation: pmShimmer 1.4s var(--pm-ease-linear) infinite; +} + +/* ── Coverage card ───────────────────────────────────────────────────────── */ +.coverage-card { + background: rgb(var(--pm-surface)); + border: 1px solid rgb(var(--pm-border) / var(--pm-border-a)); + border-radius: var(--pm-r-xl); + padding: 28px; + cursor: pointer; + transition: all 0.25s; + position: relative; + overflow: hidden; +} +.coverage-card:hover { + border-color: rgb(var(--pm-border-2) / var(--pm-border-2-a)); + transform: translateY(-2px); + box-shadow: 0 8px 32px rgb(var(--pm-violet) / 0.12); +} +.coverage-card.selected { + border-color: rgb(var(--pm-violet)); + background: rgb(var(--pm-surface-2)); +} + +/* ── Pool gauge ──────────────────────────────────────────────────────────── */ +.pool-gauge-track { + width: 100%; + height: 6px; + background: rgb(var(--pm-surface-3)); + border-radius: 3px; + overflow: hidden; +} +.pool-gauge-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(90deg, rgb(var(--pm-violet)), rgb(var(--pm-safe))); + transition: width 0.5s ease; +} + +/* ── Noise overlay (subtle texture) ─────────────────────────────────────── */ +.pm-noise::before { + content: ""; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E"); + pointer-events: none; + opacity: 0.4; +} + +/* ── Accessibility: focus states ────────────────────────────────────────── */ +a, button, input, [tabindex] { + outline: none; +} +a:focus-visible, +button:focus-visible, +input:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid rgb(var(--pm-violet)); + outline-offset: 2px; + border-radius: 4px; +} + +.pm-skip-link { + position: absolute; + left: 16px; + top: -48px; + z-index: 100; + background: rgb(var(--pm-violet)); + color: #fff; + padding: 10px 18px; + border-radius: var(--pm-r); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: top 0.15s ease; +} +.pm-skip-link:focus { + top: 16px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ── Loading skeleton ────────────────────────────────────────────────────── */ + +x 18px; + border-radius: var(--pm-r); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: top 0.15s ease; +} +.pm-skip-link:focus { + top: 16px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ── Loading skeleton ────────────────────────────────────────────────────── */ + +.pm-skeleton { + background: linear-gradient(90deg, var(--pm-surface-2) 25%, var(--pm-surface-3) 50%, var(--pm-surface-2) 75%); + background-size: 200% 100%; + animation: pmShimmer 1.4s var(--pm-ease-linear) infinite; +} diff --git a/src/app/status/page.tsx b/src/app/status/page.tsx new file mode 100644 index 0000000..893759e --- /dev/null +++ b/src/app/status/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { Navbar, Footer } from "@/components/layout"; +import { Container } from "@/components/ui"; +import { ProtocolStatusView } from "@/components/status/ProtocolStatusView"; +import { fetchProtocolStatus } from "@/lib/realtime/status"; + +export const metadata: Metadata = { + title: "Status", + description: "Current Refract Protocol status, scheduled maintenance and recent incidents.", +}; + +export const dynamic = "force-dynamic"; + +export default async function StatusPage() { + const initial = await fetchProtocolStatus(); + return ( + <> + +
+ +

+ Protocol status +

+ +
+
+