Redesign: phosphor-terminal identity and simplified 4-page structure
Information architecture (6 nav destinations -> 4): - New Overview home: metric strip (live setups, high confidence, hit rate, expectancy), top-5 setups, watchlist pulse - Market = Watchlist + Rankings merged as tabs; scoring weights moved into a collapsible disclosure - Signals = Scanner + Performance merged as tabs (Setups | Track Record) with actions inside the panels - Legacy routes redirect (/watchlist, /rankings, /scanner, /performance) Visual identity: - Warm ash-green dark palette replaces cold navy; citron lime accent replaces blue (Tailwind gray/blue remapped at config level so all components reskin) - Primary buttons: lime with ink text; long/short stays emerald/red - Typography: Bricolage Grotesque display, Instrument Sans body, IBM Plex Mono for all numerals incl. chart canvas labels - Atmosphere: graph-paper grid + citron glow + film grain; pulsing brand dot; mono-numbered nav Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+11
-9
@@ -3,11 +3,10 @@ import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
import AppShell from './components/layout/AppShell';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import WatchlistPage from './pages/WatchlistPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import SignalsPage from './pages/SignalsPage';
|
||||
import TickerDetailPage from './pages/TickerDetailPage';
|
||||
import ScannerPage from './pages/ScannerPage';
|
||||
import RankingsPage from './pages/RankingsPage';
|
||||
import PerformancePage from './pages/PerformancePage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
|
||||
export default function App() {
|
||||
@@ -17,12 +16,15 @@ export default function App() {
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<Navigate to="/watchlist" />} />
|
||||
<Route path="/watchlist" element={<WatchlistPage />} />
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/signals" element={<SignalsPage />} />
|
||||
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
|
||||
<Route path="/scanner" element={<ScannerPage />} />
|
||||
<Route path="/rankings" element={<RankingsPage />} />
|
||||
<Route path="/performance" element={<PerformancePage />} />
|
||||
{/* Legacy routes from the old 6-page layout */}
|
||||
<Route path="/watchlist" element={<Navigate to="/market" replace />} />
|
||||
<Route path="/rankings" element={<Navigate to="/market?tab=rankings" replace />} />
|
||||
<Route path="/scanner" element={<Navigate to="/signals" replace />} />
|
||||
<Route path="/performance" element={<Navigate to="/signals?tab=track" replace />} />
|
||||
<Route element={<ProtectedRoute requireAdmin />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function SettingsForm() {
|
||||
type="button"
|
||||
onClick={() => handleToggleRegistration(setting.value)}
|
||||
disabled={updateSetting.isPending}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-[#0a0e1a] disabled:opacity-50 ${
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-[#0e120f] disabled:opacity-50 ${
|
||||
setting.value === 'true' ? 'bg-gradient-to-r from-blue-600 to-sky-500' : 'bg-white/[0.1]'
|
||||
}`}
|
||||
role="switch"
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ProtectedRoute({ requireAdmin }: ProtectedRouteProps) {
|
||||
}
|
||||
|
||||
if (requireAdmin && role !== 'admin') {
|
||||
return <Navigate to="/watchlist" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
@@ -100,7 +100,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = '11px Inter, system-ui, sans-serif';
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
const v = lo + ((hi - lo) * i) / nTicks;
|
||||
@@ -141,7 +141,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
|
||||
@@ -182,7 +182,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
// Label with midpoint price and strength score
|
||||
const yMid = yTop + rectHeight / 2;
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${zone.type[0].toUpperCase()} ${formatPrice(zone.midpoint)} (${zone.strength})`,
|
||||
@@ -238,7 +238,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Labels on right side
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
||||
@@ -334,7 +334,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
// Price label on y-axis (right side)
|
||||
const price = hi - ((cy - mt) / ch) * (hi - lo);
|
||||
const priceText = formatPrice(price);
|
||||
ctx.font = '11px Inter, system-ui, sans-serif';
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
const priceMetrics = ctx.measureText(priceText);
|
||||
const labelPadX = 5;
|
||||
const labelPadY = 3;
|
||||
|
||||
@@ -3,10 +3,9 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/watchlist', label: 'Watchlist' },
|
||||
{ to: '/scanner', label: 'Scanner' },
|
||||
{ to: '/rankings', label: 'Rankings' },
|
||||
{ to: '/performance', label: 'Performance' },
|
||||
{ to: '/', label: 'Overview', end: true },
|
||||
{ to: '/market', label: 'Market', end: false },
|
||||
{ to: '/signals', label: 'Signals', end: false },
|
||||
];
|
||||
|
||||
export default function MobileNav() {
|
||||
@@ -16,7 +15,13 @@ export default function MobileNav() {
|
||||
return (
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 glass rounded-none border-x-0 border-t-0">
|
||||
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 animate-signal-pulse" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-400/60" />
|
||||
</span>
|
||||
<h1 className="font-display text-lg font-bold tracking-tight text-gradient">Signal</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-2 text-gray-400 hover:text-gray-200 transition-colors duration-200"
|
||||
@@ -40,15 +45,16 @@ export default function MobileNav() {
|
||||
}`}
|
||||
>
|
||||
<nav className="px-3 py-2 space-y-1">
|
||||
{navItems.map(({ to, label }) => (
|
||||
{navItems.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white'
|
||||
? 'bg-blue-400/[0.08] text-blue-300'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
|
||||
}`
|
||||
}
|
||||
@@ -63,7 +69,7 @@ export default function MobileNav() {
|
||||
className={({ isActive }) =>
|
||||
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white'
|
||||
? 'bg-blue-400/[0.08] text-blue-300'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
|
||||
}`
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { check as healthCheck } from '../../api/health';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/watchlist', label: 'Watchlist', icon: '◈' },
|
||||
{ to: '/scanner', label: 'Scanner', icon: '⬡' },
|
||||
{ to: '/rankings', label: 'Rankings', icon: '△' },
|
||||
{ to: '/performance', label: 'Performance', icon: '◎' },
|
||||
{ to: '/', label: 'Overview', index: '01', end: true },
|
||||
{ to: '/market', label: 'Market', index: '02', end: false },
|
||||
{ to: '/signals', label: 'Signals', index: '03', end: false },
|
||||
];
|
||||
|
||||
const linkClasses = (isActive: boolean) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-400/[0.08] text-blue-300 border border-blue-400/20'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
|
||||
}`;
|
||||
|
||||
export default function Sidebar() {
|
||||
const { role, username, logout } = useAuthStore();
|
||||
|
||||
@@ -24,41 +30,28 @@ export default function Sidebar() {
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-64 h-screen sticky top-0 glass border-r border-white/[0.06] rounded-none border-l-0 border-t-0 border-b-0">
|
||||
{/* Logo area */}
|
||||
{/* Brand */}
|
||||
<div className="px-6 py-6 border-b border-white/[0.06]">
|
||||
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 tracking-wide">TRADING INTELLIGENCE</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 animate-signal-pulse" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-400/60" />
|
||||
</span>
|
||||
<h1 className="font-display text-xl font-bold tracking-tight text-gradient">Signal</h1>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-5 space-y-1">
|
||||
{navItems.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base opacity-60">{icon}</span>
|
||||
{navItems.map(({ to, label, index, end }) => (
|
||||
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
|
||||
<span className="font-mono text-[10px] tracking-widest opacity-50">{index}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base opacity-60">⚙</span>
|
||||
<NavLink to="/admin" className={({ isActive }) => linkClasses(isActive)}>
|
||||
<span className="font-mono text-[10px] tracking-widest opacity-50">04</span>
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
@@ -81,7 +74,7 @@ export default function Sidebar() {
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
|
||||
className="w-full px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200 text-left"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
|
||||
+18
-25
@@ -1,17 +1,16 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTrades } from '../hooks/useTrades';
|
||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../components/scanner/TradeTable';
|
||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Disclosure } from '../components/ui/Disclosure';
|
||||
import { Field, Input, Select } from '../components/ui/Field';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { triggerJob } from '../api/admin';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../lib/recommendation';
|
||||
import { useTrades } from '../../hooks/useTrades';
|
||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import { Field, Input, Select } from '../ui/Field';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import type { TradeSetup } from '../../lib/types';
|
||||
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
||||
|
||||
type DirectionFilter = 'both' | 'long' | 'short';
|
||||
type ActionFilter = 'all' | 'LONG_HIGH' | 'LONG_MODERATE' | 'SHORT_HIGH' | 'SHORT_MODERATE' | 'NEUTRAL';
|
||||
@@ -93,7 +92,7 @@ function sortTrades(
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export default function ScannerPage() {
|
||||
export function SetupsPanel() {
|
||||
const { data: trades, isLoading, isError, error } = useTrades();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
@@ -132,17 +131,7 @@ export default function ScannerPage() {
|
||||
}, [trades, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Trade Scanner"
|
||||
subtitle="Asymmetric risk:reward setups across the ticker universe"
|
||||
actions={
|
||||
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
|
||||
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Filter toolbar */}
|
||||
<div className="glass-sm flex flex-wrap items-end gap-4 p-4">
|
||||
<Field label="Min Risk:Reward" htmlFor="min-rr">
|
||||
@@ -196,6 +185,11 @@ export default function ScannerPage() {
|
||||
<option value="NEUTRAL">{RECOMMENDATION_ACTION_LABELS.NEUTRAL}</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="ml-auto">
|
||||
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
|
||||
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Disclosure summary="How the scanner works & action glossary">
|
||||
@@ -215,7 +209,6 @@ export default function ScannerPage() {
|
||||
</div>
|
||||
</Disclosure>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading && <SkeletonTable rows={8} cols={8} />}
|
||||
|
||||
{isError && (
|
||||
+38
-44
@@ -1,15 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usePerformance } from '../hooks/usePerformance';
|
||||
import { triggerJob } from '../api/admin';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Disclosure } from '../components/ui/Disclosure';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { Section } from '../components/ui/Section';
|
||||
import { SkeletonCard } from '../components/ui/Skeleton';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { RECOMMENDATION_ACTION_LABELS } from '../lib/recommendation';
|
||||
import type { OutcomeBucketStats } from '../lib/types';
|
||||
import { usePerformance } from '../../hooks/usePerformance';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import { Section } from '../ui/Section';
|
||||
import { SkeletonCard } from '../ui/Skeleton';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
||||
import type { OutcomeBucketStats } from '../../lib/types';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
@@ -35,8 +34,8 @@ function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<p className="text-xs uppercase tracking-widest text-gray-500">{label}</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||
<p className="section-index">{label}</p>
|
||||
<p className={`num mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -74,13 +73,13 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
|
||||
{entries.map(([key, stats]) => (
|
||||
<tr key={key} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3 font-medium text-gray-200">{mapLabel ? mapLabel(key) : key}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-300">{stats.total}</td>
|
||||
<td className="px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
|
||||
<td className="px-4 py-3 text-right text-red-400">{stats.losses}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-400">{stats.expired}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
|
||||
<td className={`px-4 py-3 text-right font-mono ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
|
||||
<td className={`px-4 py-3 text-right font-mono ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300">{stats.total}</td>
|
||||
<td className="num px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
|
||||
<td className="num px-4 py-3 text-right text-red-400">{stats.losses}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-400">{stats.expired}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
|
||||
<td className={`num px-4 py-3 text-right ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
|
||||
<td className={`num px-4 py-3 text-right ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -89,7 +88,7 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
|
||||
);
|
||||
}
|
||||
|
||||
export default function PerformancePage() {
|
||||
export function TrackRecordPanel() {
|
||||
const { data, isLoading, isError, error } = usePerformance();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
@@ -106,29 +105,24 @@ export default function PerformancePage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Performance"
|
||||
subtitle="Do the signals actually win? Outcomes of past trade setups"
|
||||
actions={
|
||||
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
|
||||
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Disclosure summary="How outcomes are measured">
|
||||
<p className="text-xs text-gray-400">
|
||||
Each setup is replayed against the daily bars after its detection: a{' '}
|
||||
<span className="text-emerald-400">win</span> means the target was reached before the
|
||||
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
|
||||
where both levels fall inside the same day count conservatively as losses). Setups with
|
||||
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
|
||||
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost −1R — a
|
||||
positive value means the signals have been profitable on a risk-adjusted basis. The
|
||||
evaluator runs nightly after OHLCV collection.
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Disclosure summary="How outcomes are measured">
|
||||
<p className="text-xs text-gray-400">
|
||||
Each setup is replayed against the daily bars after its detection: a{' '}
|
||||
<span className="text-emerald-400">win</span> means the target was reached before the
|
||||
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
|
||||
where both levels fall inside the same day count conservatively as losses). Setups with
|
||||
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
|
||||
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost −1R — a
|
||||
positive value means the signals have been profitable on a risk-adjusted basis. The
|
||||
evaluator runs nightly after OHLCV collection.
|
||||
</p>
|
||||
</Disclosure>
|
||||
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending} className="shrink-0">
|
||||
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -1,6 +1,6 @@
|
||||
const variantStyles: Record<string, string> = {
|
||||
auto: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||
manual: 'bg-sky-500/15 text-sky-400 border-sky-500/20',
|
||||
manual: 'bg-amber-500/15 text-amber-400 border-amber-500/20',
|
||||
default: 'bg-white/[0.06] text-gray-400 border-white/[0.08]',
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInput
|
||||
|
||||
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#0d1322] ${className}`} {...rest}>
|
||||
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#151911] ${className}`} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTrades } from '../hooks/useTrades';
|
||||
import { useWatchlist } from '../hooks/useWatchlist';
|
||||
import { usePerformance } from '../hooks/usePerformance';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Section } from '../components/ui/Section';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import { recommendationActionLabel } from '../lib/recommendation';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
|
||||
}
|
||||
|
||||
function rColor(value: number | null): string {
|
||||
if (value === null) return 'text-gray-400';
|
||||
if (value > 0) return 'text-emerald-400';
|
||||
if (value < 0) return 'text-red-400';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
function Metric({ label, value, sub, valueClass = 'text-gray-100' }: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass glass-hover p-5">
|
||||
<p className="section-index">{label}</p>
|
||||
<p className={`num mt-2 text-3xl font-semibold ${valueClass}`}>{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionTag({ direction }: { direction: string }) {
|
||||
const isLong = direction === 'long';
|
||||
return (
|
||||
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||
isLong ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
|
||||
}`}>
|
||||
{direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const trades = useTrades();
|
||||
const watchlist = useWatchlist();
|
||||
const performance = usePerformance();
|
||||
|
||||
const topSetups: TradeSetup[] = useMemo(
|
||||
() => (trades.data ?? []).slice(0, 5),
|
||||
[trades.data],
|
||||
);
|
||||
|
||||
const highConfidenceCount = useMemo(
|
||||
() => (trades.data ?? []).filter((t) => (t.confidence_score ?? 0) >= 70).length,
|
||||
[trades.data],
|
||||
);
|
||||
|
||||
const topWatchlist = useMemo(
|
||||
() =>
|
||||
[...(watchlist.data ?? [])]
|
||||
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
|
||||
.slice(0, 6),
|
||||
[watchlist.data],
|
||||
);
|
||||
|
||||
const today = new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long', month: 'long', day: 'numeric',
|
||||
});
|
||||
|
||||
const stats = performance.data?.overall;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-slide-up">
|
||||
{/* Hero */}
|
||||
<div>
|
||||
<p className="num text-xs uppercase tracking-[0.22em] text-gray-500">{today}</p>
|
||||
<h1 className="font-display mt-1 text-4xl font-bold tracking-tight text-gray-100">
|
||||
Market overview
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Metric strip */}
|
||||
{(trades.isLoading || performance.isLoading) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric
|
||||
label="Live Setups"
|
||||
value={String(trades.data?.length ?? 0)}
|
||||
sub="latest per ticker & direction"
|
||||
/>
|
||||
<Metric
|
||||
label="High Confidence"
|
||||
value={String(highConfidenceCount)}
|
||||
sub="confidence ≥ 70%"
|
||||
valueClass={highConfidenceCount > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
/>
|
||||
<Metric
|
||||
label="Hit Rate"
|
||||
value={stats?.hit_rate != null ? `${stats.hit_rate.toFixed(1)}%` : '—'}
|
||||
sub={stats ? `${stats.wins}W / ${stats.losses}L evaluated` : 'no outcomes yet'}
|
||||
/>
|
||||
<Metric
|
||||
label="Expectancy"
|
||||
value={fmtR(stats?.avg_r ?? null)}
|
||||
valueClass={rColor(stats?.avg_r ?? null)}
|
||||
sub="average R per trade"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-5">
|
||||
{/* Top setups */}
|
||||
<div className="xl:col-span-3">
|
||||
<Section title="Top Setups" hint="by confidence">
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && topSetups.length === 0 && (
|
||||
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
|
||||
)}
|
||||
{topSetups.length > 0 && (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Ticker</th>
|
||||
<th className="px-4 py-3">Dir</th>
|
||||
<th className="px-4 py-3 text-right">Entry</th>
|
||||
<th className="px-4 py-3 text-right">R:R</th>
|
||||
<th className="px-4 py-3 text-right">Conf.</th>
|
||||
<th className="hidden px-4 py-3 md:table-cell">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topSetups.map((setup) => (
|
||||
<tr key={setup.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/ticker/${setup.symbol}`} className="font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
{setup.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">{setup.rr_ratio.toFixed(1)}:1</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">
|
||||
{setup.confidence_score != null ? `${Math.round(setup.confidence_score)}%` : '—'}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
|
||||
{recommendationActionLabel(setup.recommended_action)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="border-t border-white/[0.04] px-4 py-2.5">
|
||||
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
All setups →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Watchlist pulse */}
|
||||
<div className="xl:col-span-2">
|
||||
<Section title="Watchlist Pulse" hint="top by score">
|
||||
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
|
||||
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
|
||||
{watchlist.data && topWatchlist.length === 0 && (
|
||||
<Callout variant="empty">Watchlist is empty — add tickers on the Market page.</Callout>
|
||||
)}
|
||||
{topWatchlist.length > 0 && (
|
||||
<div className="glass overflow-hidden">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{topWatchlist.map((entry) => (
|
||||
<li key={entry.symbol}>
|
||||
<Link
|
||||
to={`/ticker/${entry.symbol}`}
|
||||
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
|
||||
>
|
||||
<span className="font-medium text-gray-200">{entry.symbol}</span>
|
||||
<span className="flex items-center gap-4">
|
||||
{entry.rr_ratio != null && (
|
||||
<span className="num text-xs text-gray-500">{entry.rr_ratio.toFixed(1)}:1</span>
|
||||
)}
|
||||
<span className="num text-sm font-semibold text-blue-300">
|
||||
{entry.composite_score != null ? entry.composite_score.toFixed(0) : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-white/[0.04] px-4 py-2.5">
|
||||
<Link to="/market" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
Full watchlist →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function LoginPage() {
|
||||
login.mutate(
|
||||
{ username, password },
|
||||
{
|
||||
onSuccess: () => navigate('/watchlist'),
|
||||
onSuccess: () => navigate('/'),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : 'Login failed'),
|
||||
},
|
||||
);
|
||||
@@ -25,7 +25,7 @@ export default function LoginPage() {
|
||||
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden">
|
||||
{/* Ambient glow orbs */}
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[120px] animate-glow-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-sky-500/10 rounded-full blur-[100px] animate-glow-pulse" style={{ animationDelay: '1.5s' }} />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-emerald-500/10 rounded-full blur-[100px] animate-glow-pulse" style={{ animationDelay: '1.5s' }} />
|
||||
|
||||
<div className="w-full max-w-sm space-y-8 animate-slide-up">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useWatchlist } from '../hooks/useWatchlist';
|
||||
import { useRankings } from '../hooks/useScores';
|
||||
import { WatchlistTable } from '../components/watchlist/WatchlistTable';
|
||||
import { AddTickerForm } from '../components/watchlist/AddTickerForm';
|
||||
import { RankingsTable } from '../components/rankings/RankingsTable';
|
||||
import { WeightsForm } from '../components/rankings/WeightsForm';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Disclosure } from '../components/ui/Disclosure';
|
||||
import { Select } from '../components/ui/Field';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
import type { WatchlistEntry } from '../lib/types';
|
||||
|
||||
const tabs = ['Watchlist', 'Rankings'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
type SortMode = 'name_asc' | 'name_desc' | 'score_desc' | 'score_asc';
|
||||
|
||||
function sortEntries(entries: WatchlistEntry[], mode: SortMode): WatchlistEntry[] {
|
||||
@@ -42,7 +51,7 @@ function sortEntries(entries: WatchlistEntry[], mode: SortMode): WatchlistEntry[
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export default function WatchlistPage() {
|
||||
function WatchlistPanel() {
|
||||
const { data, isLoading, isError, error } = useWatchlist();
|
||||
const [sortMode, setSortMode] = useState<SortMode>('score_desc');
|
||||
|
||||
@@ -52,16 +61,15 @@ export default function WatchlistPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader title="Watchlist" subtitle="Track your favorite tickers" actions={<AddTickerForm />} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <SkeletonTable rows={6} cols={8} />}
|
||||
|
||||
{isError && <Callout variant="error">{error?.message || 'Failed to load watchlist'}</Callout>}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<AddTickerForm />
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>Sort by</span>
|
||||
<Select
|
||||
@@ -78,8 +86,55 @@ export default function WatchlistPage() {
|
||||
</div>
|
||||
|
||||
<WatchlistTable entries={sortedEntries} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingsPanel() {
|
||||
const { data, isLoading, isError, error } = useRankings();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading && <SkeletonTable rows={8} cols={6} />}
|
||||
|
||||
{isError && (
|
||||
<Callout variant="error">Failed to load rankings: {(error as Error).message}</Callout>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Disclosure summary="Tune scoring weights">
|
||||
<WeightsForm weights={data.weights} />
|
||||
</Disclosure>
|
||||
<RankingsTable rankings={data.rankings} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarketPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab: Tab = searchParams.get('tab') === 'rankings' ? 'Rankings' : 'Watchlist';
|
||||
|
||||
const setTab = (tab: Tab) => {
|
||||
setSearchParams(tab === 'Rankings' ? { tab: 'rankings' } : {}, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Market"
|
||||
subtitle="Your watchlist and the full composite-score leaderboard"
|
||||
/>
|
||||
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
|
||||
<div className="animate-fade-in" key={activeTab}>
|
||||
{activeTab === 'Watchlist' ? <WatchlistPanel /> : <RankingsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useRankings } from '../hooks/useScores';
|
||||
import { RankingsTable } from '../components/rankings/RankingsTable';
|
||||
import { WeightsForm } from '../components/rankings/WeightsForm';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||
|
||||
export default function RankingsPage() {
|
||||
const { data, isLoading, isError, error } = useRankings();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader title="Rankings" subtitle="Composite scoring leaderboard" />
|
||||
|
||||
{isLoading && <SkeletonTable rows={8} cols={6} />}
|
||||
|
||||
{isError && (
|
||||
<Callout variant="error">Failed to load rankings: {(error as Error).message}</Callout>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<WeightsForm weights={data.weights} />
|
||||
<RankingsTable rankings={data.rankings} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default function RegisterPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden">
|
||||
<div className="absolute top-1/4 right-1/4 w-96 h-96 bg-sky-500/10 rounded-full blur-[120px] animate-glow-pulse" />
|
||||
<div className="absolute top-1/4 right-1/4 w-96 h-96 bg-emerald-500/10 rounded-full blur-[120px] animate-glow-pulse" />
|
||||
<div className="absolute bottom-1/3 left-1/3 w-80 h-80 bg-blue-500/10 rounded-full blur-[100px] animate-glow-pulse" style={{ animationDelay: '1.5s' }} />
|
||||
|
||||
<div className="w-full max-w-sm space-y-8 animate-slide-up">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
import { SetupsPanel } from '../components/signals/SetupsPanel';
|
||||
import { TrackRecordPanel } from '../components/signals/TrackRecordPanel';
|
||||
|
||||
const tabs = ['Setups', 'Track Record'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
export default function SignalsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab: Tab = searchParams.get('tab') === 'track' ? 'Track Record' : 'Setups';
|
||||
|
||||
const setTab = (tab: Tab) => {
|
||||
setSearchParams(tab === 'Track Record' ? { tab: 'track' } : {}, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Signals"
|
||||
subtitle="Detected trade setups and how past signals actually performed"
|
||||
/>
|
||||
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
|
||||
<div className="animate-fade-in" key={activeTab}>
|
||||
{activeTab === 'Setups' ? <SetupsPanel /> : <TrackRecordPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,15 @@
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background: #0a0e1a;
|
||||
background: #0e120f;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Mesh gradient background */
|
||||
h1, h2, h3 {
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Atmosphere: faint graph-paper grid + citron signal glow + film grain */
|
||||
#root {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
@@ -18,80 +22,108 @@
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
z-index: -2;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 10% 20%, rgba(56, 189, 248, 0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 80% 10%, rgba(139, 92, 246, 0.07) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 80%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
|
||||
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(196, 232, 46, 0.06) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 50% 40% at 90% 90%, rgba(52, 211, 153, 0.04) 0%, transparent 50%),
|
||||
linear-gradient(rgba(223, 242, 178, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(223, 242, 178, 0.025) 1px, transparent 1px);
|
||||
background-size: 100% 100%, 100% 100%, 48px 48px, 48px 48px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Film grain — keeps large dark areas from feeling flat */
|
||||
#root::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.5;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass card — the core building block */
|
||||
/* Panel — the core building block (kept class names so all components reskin) */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 1rem;
|
||||
background: rgba(238, 240, 233, 0.035);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(223, 242, 178, 0.09);
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
|
||||
.glass-sm {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(238, 240, 233, 0.028);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(223, 242, 178, 0.07);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.glass-hover {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.glass-hover:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(238, 240, 233, 0.06);
|
||||
border-color: rgba(214, 242, 92, 0.22);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Gradient text — reserved for the brand wordmark only */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #38bdf8, #60a5fa);
|
||||
background: linear-gradient(120deg, #d6f25c, #6ee7b7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Primary button — single blue accent */
|
||||
/* Primary button — luminous citron with ink text. The signature element. */
|
||||
.btn-primary {
|
||||
background: rgba(59, 130, 246, 0.85);
|
||||
color: white;
|
||||
border: 1px solid rgba(96, 165, 250, 0.35);
|
||||
background: #c3e82e;
|
||||
color: #16190a;
|
||||
border: 1px solid rgba(214, 242, 92, 0.5);
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: rgb(59, 130, 246);
|
||||
box-shadow: 0 0 16px rgba(59, 130, 246, 0.25);
|
||||
background: #d6f25c;
|
||||
box-shadow: 0 0 20px rgba(196, 232, 46, 0.35);
|
||||
}
|
||||
|
||||
/* Glass input */
|
||||
/* Inputs */
|
||||
.input-glass {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(238, 240, 233, 0.04);
|
||||
border: 1px solid rgba(223, 242, 178, 0.12);
|
||||
border-radius: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
color: #eef0e9;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.input-glass::placeholder {
|
||||
color: rgba(148, 163, 184, 0.5);
|
||||
color: rgba(149, 157, 135, 0.55);
|
||||
}
|
||||
.input-glass:focus {
|
||||
outline: none;
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15), 0 0 20px rgba(99, 102, 241, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(214, 242, 92, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(196, 232, 46, 0.12);
|
||||
background: rgba(238, 240, 233, 0.06);
|
||||
}
|
||||
|
||||
/* Numbers — every metric reads in tabular mono */
|
||||
.num {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Section index label, e.g. "01 / Watchlist" */
|
||||
.section-index {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: #6e7562;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -103,10 +135,10 @@
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(223, 242, 178, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(223, 242, 178, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user