Horizon redesign: space theme, visual dashboard, chart reskin
Replaces the citron/green glass theme with the Horizon direction (mockup at /design-horizon): space-void background, starfield, dim Mars horizon, rim-cyan accent, rose for negative. Palette validated for CVD separation and contrast on the dark surface. - tailwind.config: gray -> cool space neutrals, blue/emerald -> cyan scale, red -> rose; display font Space Grotesk - globals.css: Horizon tokens, atmosphere, denser glass, cyan buttons/inputs, price-rail / R-bar / radar chart CSS - Dashboard rebuilt: verdict hero, top-pick card with spatial price rail, KPI tiles, open positions as diverging R-bars with expandable detail + trade chart (OHLCV since entry, entry/stop/target levels), radar list with per-setup disqualify reason, watchlist chips - OpenTradesPanel: table replaced by R-bar rows; all fields kept in the drill-down (shares, P&L, alpha, trailing stop, sell) - qualification: disqualifyReason() mirrors qualifiesSetup rule order - CandlestickChart: canvas colors moved to Horizon palette; S/R now cyan/neutral so rose stays reserved for the stop level - ScoreCard: radar score fingerprint with hover values - Design mockup pages included (routed in App.tsx) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -7,11 +7,11 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-[#0e120f] text-gray-100 font-sans">
|
||||
<body class="bg-[#0a0b11] text-gray-100 font-sans">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -9,12 +9,18 @@ import SignalsPage from './pages/SignalsPage';
|
||||
import RegimePage from './pages/RegimePage';
|
||||
import TickerDetailPage from './pages/TickerDetailPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import DesignMockupsPage from './pages/DesignMockupsPage';
|
||||
import DesignHorizonPage from './pages/DesignHorizonPage';
|
||||
import DesignOrbitPage from './pages/DesignOrbitPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/design-mockups" element={<DesignMockupsPage />} />
|
||||
<Route path="/design-horizon" element={<DesignHorizonPage />} />
|
||||
<Route path="/design-orbit" element={<DesignOrbitPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
@@ -160,7 +160,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const nTicks = 6;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
@@ -178,7 +178,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const labelInterval = Math.max(Math.floor(visibleData.length / 8), 1);
|
||||
for (let i = 0; i < visibleData.length; i += labelInterval) {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Volume', ml, volumeTop - 13);
|
||||
ctx.textAlign = 'right';
|
||||
@@ -206,20 +206,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const bullish = bar.close >= bar.open;
|
||||
const yVolume = volumeScale(bar.volume);
|
||||
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
|
||||
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
|
||||
ctx.fillStyle = bullish ? 'rgba(47, 157, 178, 0.32)' : 'rgba(227, 106, 88, 0.28)';
|
||||
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
||||
});
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
// Support = rim-cyan, resistance = neutral ink — rose stays reserved for
|
||||
// the stop level so S/R never impersonates trade levels.
|
||||
const color = isSupport ? '#2f9db2' : '#9aa0b0';
|
||||
const yMid = yScale(m.price);
|
||||
|
||||
if (m.high > m.low) {
|
||||
const yTop = yScale(m.high);
|
||||
const rectHeight = Math.max(yScale(m.low) - yTop, 2);
|
||||
ctx.fillStyle = isSupport ? 'rgba(16, 185, 129, 0.12)' : 'rgba(239, 68, 68, 0.12)';
|
||||
ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(154, 160, 176, 0.10)';
|
||||
ctx.fillRect(ml, yTop, cw, rectHeight);
|
||||
}
|
||||
|
||||
@@ -253,10 +255,10 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Stop-loss zone: red semi-transparent rectangle between entry and stop-loss
|
||||
const slTop = Math.min(entryY, stopY);
|
||||
const slHeight = Math.max(Math.abs(stopY - entryY), 1);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.13)';
|
||||
ctx.fillStyle = 'rgba(227, 106, 88, 0.13)';
|
||||
ctx.fillRect(ml, slTop, cw, slHeight);
|
||||
// Stop-loss border
|
||||
ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)';
|
||||
ctx.strokeStyle = 'rgba(227, 106, 88, 0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
@@ -268,10 +270,10 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Take-profit zone: green semi-transparent rectangle between entry and target
|
||||
const tpTop = Math.min(entryY, targetY);
|
||||
const tpHeight = Math.max(Math.abs(targetY - entryY), 1);
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.13)';
|
||||
ctx.fillStyle = 'rgba(47, 157, 178, 0.13)';
|
||||
ctx.fillRect(ml, tpTop, cw, tpHeight);
|
||||
// Target border
|
||||
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
|
||||
ctx.strokeStyle = 'rgba(47, 157, 178, 0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
@@ -280,8 +282,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Entry price: dashed horizontal line (blue/white)
|
||||
ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
// Entry price: dashed horizontal line (neutral ink)
|
||||
ctx.strokeStyle = 'rgba(154, 160, 176, 0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
@@ -293,11 +295,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Labels on right side
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
ctx.fillStyle = 'rgba(154, 160, 176, 0.95)';
|
||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
|
||||
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
|
||||
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.8)';
|
||||
ctx.fillStyle = 'rgba(110, 201, 219, 0.9)';
|
||||
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
}
|
||||
|
||||
@@ -316,7 +318,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
|
||||
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
|
||||
ctx.fillStyle = '#0e120f';
|
||||
ctx.fillStyle = '#0a0b11';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, ml + 6, py);
|
||||
@@ -327,7 +329,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
visibleData.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
const bullish = bar.close >= bar.open;
|
||||
const color = bullish ? '#10b981' : '#ef4444';
|
||||
const color = bullish ? '#2f9db2' : '#e36a58';
|
||||
|
||||
const yHigh = yScale(bar.high);
|
||||
const yLow = yScale(bar.low);
|
||||
@@ -441,11 +443,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const labelX = ml + cw + 2;
|
||||
const labelY = cy - labelH / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.fillStyle = 'rgba(35, 39, 51, 0.95)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.fillStyle = '#edeef3';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceText, labelX + labelPadX, cy);
|
||||
@@ -462,11 +464,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const dateLabelX = cx - dateLabelW / 2;
|
||||
const dateLabelY = H - mb + 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.fillStyle = 'rgba(35, 39, 51, 0.95)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(dateLabelX, dateLabelY, dateLabelW, dateLabelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.fillStyle = '#edeef3';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(dateText, cx, dateLabelY + dateLabelH / 2);
|
||||
@@ -667,8 +669,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
||||
<span>Direction</span><span class="text-right text-gray-200">${tradeSetup.direction}</span>
|
||||
<span>Entry</span><span class="text-right text-blue-300">${formatPrice(tradeSetup.entry_price)}</span>
|
||||
<span>Stop</span><span class="text-right text-red-400">${formatPrice(tradeSetup.stop_loss)}</span>
|
||||
<span>Target</span><span class="text-right text-green-400">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>Stop</span><span class="text-right text-red-300">${formatPrice(tradeSetup.stop_loss)}</span>
|
||||
<span>Target</span><span class="text-right text-emerald-300">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Horizon chart primitives shared by the dashboard and ticker page.
|
||||
* All colors come from the :root tokens in globals.css; the data marks
|
||||
* (--up / --down) are validated against the dark surface.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { OHLCVBar } from '../../lib/types';
|
||||
|
||||
function fmt(n: number, digits = 2): string {
|
||||
return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function signedR(r: number): string {
|
||||
return `${r > 0 ? '+' : r < 0 ? '−' : ''}${fmt(Math.abs(r))}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* RBar — diverging R-multiple bar growing from a centered zero line */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) {
|
||||
const pct = r == null ? 0 : Math.min(Math.abs(r) / Math.max(max, 0.01), 1) * 50;
|
||||
return (
|
||||
<div className="hz-rbar" role="img" aria-label={r == null ? 'no R value' : `${signedR(r)} R`}>
|
||||
<span className="hz-rbar-zero" />
|
||||
{r != null && (
|
||||
<span
|
||||
className="hz-rbar-fill"
|
||||
style={{
|
||||
left: r >= 0 ? '50%' : `${50 - pct}%`,
|
||||
width: `${pct}%`,
|
||||
background: r >= 0 ? 'var(--up)' : 'var(--down)',
|
||||
borderRadius: r >= 0 ? '0 4px 4px 0' : '4px 0 0 4px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PriceRail — stop → entry → now → target laid out spatially */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function PriceRail({
|
||||
direction, entry, stop, target, current,
|
||||
}: {
|
||||
direction: string;
|
||||
entry: number;
|
||||
stop: number;
|
||||
target: number;
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [entry, stop, target, ...(current != null ? [current] : [])];
|
||||
const span = Math.max(...points) - Math.min(...points) || 1;
|
||||
const lo = Math.min(...points) - span * 0.06;
|
||||
const hi = Math.max(...points) + span * 0.06;
|
||||
const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));
|
||||
const risk = Math.abs(entry - stop);
|
||||
const rNow = current != null && risk > 0
|
||||
? (direction === 'long' ? current - entry : entry - current) / risk
|
||||
: null;
|
||||
const rTarget = risk > 0 ? Math.abs(target - entry) / risk : null;
|
||||
const inProfit = rNow != null && rNow >= 0;
|
||||
const progressLeft = current != null ? Math.min(pct(entry), pct(current)) : 0;
|
||||
const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0;
|
||||
return (
|
||||
<div className="hz-rail" role="img" aria-label={
|
||||
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, target ${fmt(target)}`
|
||||
}>
|
||||
<div className="hz-rail-track" />
|
||||
<div
|
||||
className="hz-rail-risk"
|
||||
style={{ left: `${Math.min(pct(stop), pct(entry))}%`, width: `${Math.abs(pct(entry) - pct(stop))}%` }}
|
||||
/>
|
||||
{current != null && (
|
||||
<div
|
||||
className="hz-rail-progress"
|
||||
style={{
|
||||
left: `${progressLeft}%`,
|
||||
width: `${progressWidth}%`,
|
||||
background: inProfit ? 'var(--up)' : 'var(--down)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(stop)}%` }}>
|
||||
<span className="hz-rail-tick" style={{ background: 'var(--down)' }} />
|
||||
<span className="hz-rail-label">
|
||||
<em>stop</em>
|
||||
<b>{fmt(stop)}</b>
|
||||
<i>−1R</i>
|
||||
</span>
|
||||
</div>
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(entry)}%` }}>
|
||||
<span className="hz-rail-tick" style={{ background: 'var(--ink-2)' }} />
|
||||
<span className="hz-rail-label">
|
||||
<em>entry</em>
|
||||
<b>{fmt(entry)}</b>
|
||||
</span>
|
||||
</div>
|
||||
{current != null && (
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(current)}%` }}>
|
||||
<span className="hz-rail-dot" style={{ background: inProfit ? 'var(--up)' : 'var(--down)' }} />
|
||||
<span className="hz-rail-label hz-rail-label-top">
|
||||
<em>now</em>
|
||||
<b style={{ color: inProfit ? 'var(--up-text)' : 'var(--down-text)' }}>{fmt(current)}</b>
|
||||
{rNow != null && <i>{signedR(rNow)}R</i>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }}>
|
||||
<span className="hz-rail-ring" />
|
||||
<span className="hz-rail-label">
|
||||
<em>target</em>
|
||||
<b>{fmt(target)}</b>
|
||||
{rTarget != null && <i>+{fmt(rTarget, 1)}R</i>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* RadarChart — score fingerprint over n axes (0–100), hover for values */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface RadarAxis {
|
||||
label: string;
|
||||
value: number;
|
||||
/** Longer name for the tooltip / screen reader (falls back to label). */
|
||||
full?: string;
|
||||
}
|
||||
|
||||
export function RadarChart({
|
||||
axes, size = 120, labels = true,
|
||||
}: { axes: RadarAxis[]; size?: number; labels?: boolean }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const n = axes.length;
|
||||
if (n < 3) return null;
|
||||
const pad = labels ? 74 : 10;
|
||||
const vb = size + pad * 2;
|
||||
const c = vb / 2;
|
||||
const r = size / 2;
|
||||
const angle = (i: number) => ((-90 + (i * 360) / n) * Math.PI) / 180;
|
||||
const pt = (v: number, i: number): [number, number] => {
|
||||
const a = angle(i);
|
||||
return [c + (Math.cos(a) * r * Math.max(0, Math.min(100, v))) / 100, c + (Math.sin(a) * r * Math.max(0, Math.min(100, v))) / 100];
|
||||
};
|
||||
const poly = (vals: number[]) =>
|
||||
vals.map((v, i) => pt(v, i).map((x) => x.toFixed(1)).join(',')).join(' ');
|
||||
const tip = hover !== null ? pt(axes[hover].value, hover) : null;
|
||||
return (
|
||||
<div className="hz-radar-wrap" style={{ width: vb, height: vb }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${vb} ${vb}`}
|
||||
width={vb}
|
||||
height={vb}
|
||||
role="img"
|
||||
aria-label={`Score fingerprint: ${axes.map((a) => `${a.full ?? a.label} ${Math.round(a.value)}`).join(', ')} (0–100)`}
|
||||
>
|
||||
{[50, 100].map((ring) => (
|
||||
<polygon key={ring} points={poly(Array(n).fill(ring))} fill="none" stroke="var(--grid)" strokeWidth="1" />
|
||||
))}
|
||||
{axes.map((a, i) => {
|
||||
const [x, y] = pt(100, i);
|
||||
return <line key={a.label} x1={c} y1={c} x2={x} y2={y} stroke="var(--grid)" strokeWidth="1" />;
|
||||
})}
|
||||
<polygon
|
||||
points={poly(axes.map((a) => a.value))}
|
||||
fill="var(--up)" fillOpacity="0.13"
|
||||
stroke="var(--up)" strokeWidth="2" strokeLinejoin="round"
|
||||
/>
|
||||
{axes.map((a, i) => {
|
||||
const [x, y] = pt(a.value, i);
|
||||
return (
|
||||
<circle
|
||||
key={a.label} cx={x} cy={y} r={hover === i ? 4.5 : 3}
|
||||
fill="var(--up)" stroke="var(--surface)" strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{labels && axes.map((a, i) => {
|
||||
const ang = angle(i);
|
||||
const x = c + Math.cos(ang) * (r + 13);
|
||||
const y = c + Math.sin(ang) * (r + 13);
|
||||
const anchor = Math.cos(ang) > 0.3 ? 'start' : Math.cos(ang) < -0.3 ? 'end' : 'middle';
|
||||
const dy = Math.sin(ang) < -0.3 ? -2 : Math.sin(ang) > 0.3 ? 9 : 4;
|
||||
return (
|
||||
<text key={a.label} x={x} y={y + dy} textAnchor={anchor} className="hz-radar-axis">
|
||||
{a.label} <tspan className="hz-radar-axisval">{Math.round(a.value)}</tspan>
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{/* hit targets on top — comfortably larger than the dots, keyboard-reachable */}
|
||||
{axes.map((a, i) => {
|
||||
const [x, y] = pt(a.value, i);
|
||||
return (
|
||||
<circle
|
||||
key={`hit-${a.label}`} cx={x} cy={y} r="13"
|
||||
fill="transparent" style={{ cursor: 'default', outline: 'none' }}
|
||||
tabIndex={0}
|
||||
aria-label={`${a.full ?? a.label}: ${Math.round(a.value)} of 100`}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(i)}
|
||||
onBlur={() => setHover(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{hover !== null && tip && (
|
||||
<div
|
||||
className="hz-radar-tip"
|
||||
style={{ left: `${(tip[0] / vb) * 100}%`, top: `${(tip[1] / vb) * 100}%` }}
|
||||
>
|
||||
<b>{axes[hover].full ?? axes[hover].label}</b>
|
||||
<span className="num">{Math.round(axes[hover].value)} / 100</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* TradeChart — close path since entry + entry / stop / target levels */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function TradeChart({
|
||||
direction, entry, stop, target, bars, openedAt,
|
||||
}: {
|
||||
direction: string;
|
||||
entry: number;
|
||||
/** Current stop level (trailing stop if active, else the initial stop). */
|
||||
stop: number;
|
||||
target: number;
|
||||
bars: OHLCVBar[];
|
||||
openedAt: string;
|
||||
}) {
|
||||
const openedDate = openedAt.slice(0, 10);
|
||||
const closes = bars
|
||||
.filter((b) => b.date.slice(0, 10) >= openedDate)
|
||||
.map((b) => b.close);
|
||||
const series = [entry, ...closes];
|
||||
if (series.length < 3) return null;
|
||||
|
||||
const w = 560; const h = 150;
|
||||
const padL = 8; const padR = 96; const padT = 10; const padB = 12;
|
||||
const isShort = direction === 'short';
|
||||
const vals = [...series, stop, entry];
|
||||
let lo = Math.min(...vals);
|
||||
let hi = Math.max(...vals);
|
||||
const range = hi - lo || 1;
|
||||
// Only stretch the scale to the target when it doesn't flatten the price action.
|
||||
const targetFits = isShort ? lo - target <= range * 0.9 : target - hi <= range * 0.9;
|
||||
if (targetFits) { lo = Math.min(lo, target); hi = Math.max(hi, target); }
|
||||
const pad = (hi - lo) * 0.07;
|
||||
lo -= pad; hi += pad;
|
||||
const plotW = w - padL - padR;
|
||||
const plotH = h - padT - padB;
|
||||
const px = (i: number) => padL + (i / (series.length - 1)) * plotW;
|
||||
const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
|
||||
const path = series.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
|
||||
const last = series[series.length - 1];
|
||||
const perShare = isShort ? entry - last : last - entry;
|
||||
const col = perShare >= 0 ? 'var(--up)' : 'var(--down)';
|
||||
const labelX = w - padR + 10;
|
||||
const entryY = py(entry);
|
||||
let stopY = py(stop);
|
||||
if (Math.abs(stopY - entryY) < 11) stopY = entryY + (stopY >= entryY ? 11 : -11);
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className="hz-tradechart"
|
||||
role="img"
|
||||
aria-label={`Price since entry ${fmt(entry)}, now ${fmt(last)}, stop ${fmt(stop)}, target ${fmt(target)}`}
|
||||
>
|
||||
<line x1={padL} x2={w - padR} y1={entryY} y2={entryY} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
||||
<text x={labelX} y={entryY + 3.5} className="hz-lvl">entry {fmt(entry)}</text>
|
||||
<line x1={padL} x2={w - padR} y1={py(stop)} y2={py(stop)} stroke="var(--down)" strokeWidth="1.5" opacity="0.85" />
|
||||
<text x={labelX} y={stopY + 3.5} className="hz-lvl hz-lvl-down">stop {fmt(stop)}</text>
|
||||
{targetFits ? (
|
||||
<>
|
||||
<line x1={padL} x2={w - padR} y1={py(target)} y2={py(target)} stroke="var(--up)" strokeWidth="1" opacity="0.6" />
|
||||
<text x={labelX} y={py(target) + 3.5} className="hz-lvl hz-lvl-up">target {fmt(target)}</text>
|
||||
</>
|
||||
) : (
|
||||
<text x={labelX} y={isShort ? h - padB : padT + 4} className="hz-lvl hz-lvl-up">
|
||||
target {fmt(target)} {isShort ? '↓' : '↑'}
|
||||
</text>
|
||||
)}
|
||||
<path d={path} fill="none" stroke={col} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<circle cx={px(series.length - 1)} cy={py(last)} r="4" fill={col} stroke="var(--surface)" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +1,249 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
|
||||
import { useTickerNames } from '../../hooks/useTickers';
|
||||
import { getOHLCV } from '../../api/ohlcv';
|
||||
import { tradePnl } from '../../lib/paperTrade';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
import { RBar, TradeChart } from '../charts/horizon';
|
||||
import { Section } from '../ui/Section';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import type { PaperTrade } from '../../lib/types';
|
||||
|
||||
function money(v: number): string {
|
||||
const sign = v >= 0 ? '+' : '−';
|
||||
return `${sign}$${Math.abs(v).toFixed(2)}`;
|
||||
}
|
||||
function pnlColor(v: number): string {
|
||||
if (v > 0) return 'text-emerald-400';
|
||||
if (v < 0) return 'text-red-400';
|
||||
if (v > 0) return 'text-emerald-300';
|
||||
if (v < 0) return 'text-red-300';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
function DirTag({ 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-300' : 'bg-red-500/15 text-red-300'
|
||||
}`}>
|
||||
{direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, valueClass = 'text-gray-100' }: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">{label}</dt>
|
||||
<dd className={`num mt-0.5 text-sm ${valueClass}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expanded row: full trade detail + price chart with entry/stop/target levels. */
|
||||
function TradeDetail({ trade, exitLabel, onClose, closing }: {
|
||||
trade: PaperTrade;
|
||||
exitLabel: string | null;
|
||||
onClose: () => void;
|
||||
closing: boolean;
|
||||
}) {
|
||||
const p = tradePnl(trade);
|
||||
const stopLevel = trade.trailing_stop ?? trade.stop_loss;
|
||||
const ohlcv = useQuery({
|
||||
queryKey: ['ohlcv', trade.symbol],
|
||||
queryFn: () => getOHLCV(trade.symbol),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-2 pb-4 pt-1">
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
|
||||
<Detail label="opened" value={`${opened} · ${trade.shares} shares`} />
|
||||
<Detail label="entry → now" value={
|
||||
`${formatPrice(trade.entry_price)} → ${trade.current_price != null ? formatPrice(trade.current_price) : '—'}`
|
||||
} />
|
||||
<Detail
|
||||
label="P&L"
|
||||
value={p ? `${money(p.pnl)} · ${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
|
||||
valueClass={p ? pnlColor(p.pnl) : 'text-gray-500'}
|
||||
/>
|
||||
<Detail
|
||||
label="alpha vs SPY"
|
||||
value={
|
||||
trade.alpha_pct != null
|
||||
? `${trade.alpha_pct >= 0 ? '+' : ''}${trade.alpha_pct.toFixed(1)}%${trade.alpha_usd != null ? ` · ${money(trade.alpha_usd)}` : ''}`
|
||||
: '—'
|
||||
}
|
||||
valueClass={trade.alpha_pct != null ? pnlColor(trade.alpha_pct) : 'text-gray-500'}
|
||||
/>
|
||||
<Detail
|
||||
label="stop"
|
||||
value={
|
||||
<>
|
||||
{formatPrice(stopLevel)}
|
||||
{trade.trailing_stop != null && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-500">
|
||||
trailing{trade.trailing_distance_pct != null ? ` · ${Math.abs(trade.trailing_distance_pct).toFixed(1)}% away` : ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Detail label="target" value={formatPrice(trade.target)} />
|
||||
<Detail label="exit rule" value={exitLabel ?? 'target/stop'} />
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={closing}
|
||||
className="rounded-md border border-white/[0.1] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Sell at market
|
||||
</button>
|
||||
</div>
|
||||
</dl>
|
||||
{ohlcv.data && (
|
||||
<div>
|
||||
<p className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">
|
||||
since entry · entry / stop / target
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<TradeChart
|
||||
direction={trade.direction}
|
||||
entry={trade.entry_price}
|
||||
stop={stopLevel}
|
||||
target={trade.target}
|
||||
bars={ohlcv.data}
|
||||
openedAt={trade.opened_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenTradesPanel() {
|
||||
const { data: trades, isLoading } = usePaperTrades('open');
|
||||
const { data: policy } = useExitPolicy();
|
||||
const tickerNames = useTickerNames();
|
||||
const close = useClosePaperTrade();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const exitLabel = policy
|
||||
? policy.mode === 'atr_trailing'
|
||||
? `auto-exit: ${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trail / ${policy.hold_days}d max`
|
||||
? `${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max`
|
||||
: policy.mode === 'trailing'
|
||||
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
|
||||
? `trailing ${Math.round(policy.trailing_pct)}%`
|
||||
: policy.mode === 'time'
|
||||
? `auto-exit: ${policy.hold_days}d hold`
|
||||
: 'auto-exit: target/stop'
|
||||
? `${policy.hold_days}d hold`
|
||||
: 'target/stop'
|
||||
: null;
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of trades ?? []) {
|
||||
const rows = trades ?? [];
|
||||
|
||||
const { totals, rMax } = useMemo(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, alphaUsd = 0, alphaPriced = 0, rMax = 1;
|
||||
for (const t of rows) {
|
||||
const p = tradePnl(t);
|
||||
if (p) {
|
||||
priced += 1;
|
||||
pnl += p.pnl;
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
if (p.r != null) rMax = Math.max(rMax, Math.abs(p.r));
|
||||
}
|
||||
if (t.alpha_usd != null) {
|
||||
alphaUsd += t.alpha_usd;
|
||||
alphaPriced += 1;
|
||||
}
|
||||
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
|
||||
}
|
||||
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
|
||||
}, [trades]);
|
||||
return { totals: { pnl, winners, losers, alphaUsd, alphaPriced }, rMax };
|
||||
}, [rows]);
|
||||
|
||||
if (isLoading) return null;
|
||||
const rows = trades ?? [];
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Open Trades"
|
||||
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
|
||||
title="Open Positions"
|
||||
hint={rows.length > 0
|
||||
? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼ · R-multiple to entry · select a row for detail`
|
||||
: 'paper trading'}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Callout variant="empty">
|
||||
No open paper trades. Open a ticker and tap “Mark as taken” on a setup to start.
|
||||
</Callout>
|
||||
) : (
|
||||
<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">Shares</th>
|
||||
<th className="px-4 py-3 text-right">Entry</th>
|
||||
<th className="px-4 py-3 text-right">Now</th>
|
||||
<th className="px-4 py-3 text-right">P&L</th>
|
||||
<th className="px-4 py-3 text-right">%</th>
|
||||
<th className="px-4 py-3 text-right">R</th>
|
||||
<th className="px-4 py-3 text-right">Alpha</th>
|
||||
<th className="px-4 py-3 text-right">Trail Stop</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => {
|
||||
const p = tradePnl(t);
|
||||
return (
|
||||
<tr key={t.id} className="border-b border-white/[0.04] hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
|
||||
<div className="glass px-4 py-1">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{rows.map((t) => {
|
||||
const p = tradePnl(t);
|
||||
const open = expandedId === t.id;
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setExpandedId(open ? null : t.id)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpandedId(open ? null : t.id);
|
||||
}
|
||||
}}
|
||||
aria-expanded={open}
|
||||
className="grid w-full cursor-pointer grid-cols-[110px_1fr_60px_16px] items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-white/[0.03] sm:grid-cols-[130px_150px_1fr_70px_16px]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/ticker/${t.symbol}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-blue-300 transition-colors hover:text-blue-200"
|
||||
>
|
||||
{t.symbol}
|
||||
</Link>
|
||||
{tickerNames.get(t.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[150px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(t.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{t.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300">{t.shares}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">
|
||||
{t.current_price != null ? formatPrice(t.current_price) : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right font-semibold ${p ? pnlColor(p.pnl) : 'text-gray-500'}`}>
|
||||
{p ? money(p.pnl) : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${p ? pnlColor(p.pct) : 'text-gray-500'}`}>
|
||||
{p ? `${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||
<DirTag direction={t.direction} />
|
||||
</span>
|
||||
<span className="num hidden text-xs text-gray-400 sm:block">
|
||||
{formatPrice(t.entry_price)} → {t.current_price != null ? formatPrice(t.current_price) : '—'}
|
||||
</span>
|
||||
<RBar r={p?.r ?? null} max={rMax} />
|
||||
<span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
|
||||
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
|
||||
{t.trailing_stop != null ? (
|
||||
<>
|
||||
{formatPrice(t.trailing_stop)}
|
||||
{t.trailing_distance_pct != null && (
|
||||
<span className="ml-1 text-[10px] text-gray-500">
|
||||
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
|
||||
close.mutate({ id: t.id });
|
||||
}
|
||||
}}
|
||||
disabled={close.isPending}
|
||||
className="rounded-md border border-white/[0.1] px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/[0.08]">
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
|
||||
Total unrealized P&L · alpha vs S&P 500
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
|
||||
{money(totals.pnl)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</span>
|
||||
<span className={`text-[10px] text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<TradeDetail
|
||||
trade={t}
|
||||
exitLabel={exitLabel}
|
||||
closing={close.isPending}
|
||||
onClose={() => {
|
||||
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
|
||||
close.mutate({ id: t.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* company name for screen readers / row context */}
|
||||
<span className="sr-only">{tickerNames.get(t.symbol.toUpperCase()) ?? ''}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-t border-white/[0.06] px-2 py-2.5 text-xs text-gray-500">
|
||||
<span>total unrealized · alpha vs S&P 500{exitLabel ? ` · auto-exit: ${exitLabel}` : ''}</span>
|
||||
<span className="num flex gap-4">
|
||||
<b className={`font-semibold ${pnlColor(totals.pnl)}`}>{money(totals.pnl)}</b>
|
||||
<b className={`font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -5,6 +5,10 @@ import MobileNav from './MobileNav';
|
||||
export default function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen text-gray-100">
|
||||
<div className="app-horizon" aria-hidden="true">
|
||||
<div className="app-horizon-rim" />
|
||||
<div className="app-horizon-planet" />
|
||||
</div>
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<MobileNav />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { DimensionBreakdownPanel } from '../ticker/DimensionBreakdownPanel';
|
||||
import { RadarChart } from '../charts/horizon';
|
||||
import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
|
||||
|
||||
interface ScoreCardProps {
|
||||
@@ -18,9 +19,9 @@ function scoreColor(score: number): string {
|
||||
}
|
||||
|
||||
function ringGradient(score: number): string {
|
||||
if (score > 70) return '#10b981';
|
||||
if (score > 70) return '#2f9db2';
|
||||
if (score >= 40) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
return '#e36a58';
|
||||
}
|
||||
|
||||
function barGradient(score: number): string {
|
||||
@@ -64,7 +65,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
{showComposite && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{compositeScore !== null ? (
|
||||
<ScoreRing score={compositeScore} />
|
||||
) : (
|
||||
@@ -87,6 +88,18 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{dimensions.length >= 3 && (
|
||||
<div className="ml-auto" title="Score fingerprint — hover a corner for the exact value">
|
||||
<RadarChart
|
||||
axes={dimensions.map((d) => ({
|
||||
label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension,
|
||||
full: d.dimension,
|
||||
value: d.score,
|
||||
}))}
|
||||
size={104}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -63,6 +63,42 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a setup does NOT clear the gate — the first failing rule, phrased for the
|
||||
* dashboard's radar list. Returns null when the setup qualifies. Mirrors
|
||||
* qualifiesSetup rule-for-rule (keep the order in sync).
|
||||
*/
|
||||
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
||||
if (setup.rr_ratio < config.min_rr) {
|
||||
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
||||
}
|
||||
if (setup.current_price != null && liveRiskReward(setup, setup.current_price) < config.min_rr) {
|
||||
return 'price has run — live R:R below gate';
|
||||
}
|
||||
const targetProbability = primaryTargetProbability(setup);
|
||||
if (targetProbability == null || targetProbability <= 0) return 'no target probability';
|
||||
if ((setup.confidence_score ?? 0) < config.min_confidence) {
|
||||
return `confidence below ${config.min_confidence.toFixed(0)}%`;
|
||||
}
|
||||
if (config.min_momentum_percentile > 0) {
|
||||
if (setup.direction === 'short') return 'short — momentum gate is long-only';
|
||||
if (setup.momentum_percentile == null) return 'no residual momentum rank';
|
||||
if (setup.momentum_percentile < config.min_momentum_percentile) {
|
||||
return `momentum below ${config.min_momentum_percentile.toFixed(0)}th %ile`;
|
||||
}
|
||||
}
|
||||
if (config.exclude_neutral) {
|
||||
const actionDir = actionDirection(setup.recommended_action);
|
||||
if (actionDir === 'neutral') return 'model action neutral';
|
||||
if (actionDir !== setup.direction) return 'counter to model bias';
|
||||
}
|
||||
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||
return 'conviction below High';
|
||||
}
|
||||
if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') return 'risk flags not clean';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
|
||||
* the highest residual 12-1 momentum percentile among qualified setups. Returns
|
||||
|
||||
@@ -10,10 +10,12 @@ import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Section } from '../components/ui/Section';
|
||||
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
|
||||
import { PriceRail } from '../components/charts/horizon';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import { tradePnl } from '../lib/paperTrade';
|
||||
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification';
|
||||
import {
|
||||
qualifiesSetup, disqualifyReason, activationSummary, primaryTargetProbability,
|
||||
} from '../lib/qualification';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
@@ -23,8 +25,8 @@ function fmtR(value: number | null): string {
|
||||
|
||||
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';
|
||||
if (value > 0) return 'text-emerald-300';
|
||||
if (value < 0) return 'text-red-300';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ function Metric({ label, value, sub, valueClass = 'text-gray-100' }: {
|
||||
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>
|
||||
<p className={`font-display mt-2 text-3xl font-semibold ${valueClass}`}>{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -52,7 +54,7 @@ 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'
|
||||
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
|
||||
}`}>
|
||||
{direction}
|
||||
</span>
|
||||
@@ -66,10 +68,90 @@ function convictionLabel(action: TradeSetup['recommended_action']): string {
|
||||
return '—';
|
||||
}
|
||||
|
||||
function convictionClass(action: TradeSetup['recommended_action']): string {
|
||||
if (action?.endsWith('_HIGH')) return 'text-emerald-300';
|
||||
if (action?.endsWith('_MODERATE')) return 'text-amber-300';
|
||||
return 'text-gray-400';
|
||||
/** The one focal card: today's top qualified setup as a spatial price rail. */
|
||||
function FocusCard({ setup, name, gateNote }: {
|
||||
setup: TradeSetup;
|
||||
name: string | undefined;
|
||||
gateNote: string;
|
||||
}) {
|
||||
const prob = primaryTargetProbability(setup);
|
||||
return (
|
||||
<section className="glass p-6 sm:p-7" aria-label="Top qualified setup">
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="num mt-2.5 whitespace-nowrap rounded-full border border-[#ff6a45]/40 px-2.5 py-1 text-[9.5px] font-semibold uppercase tracking-[0.2em] text-[#ff6a45]">
|
||||
top pick
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display text-4xl font-bold tracking-tight text-gray-100 sm:text-5xl">
|
||||
{setup.symbol}
|
||||
</h2>
|
||||
{name && <span className="text-sm text-gray-400">{name}</span>}
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<DirectionTag direction={setup.direction} />
|
||||
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
||||
conviction {convictionLabel(setup.recommended_action)}
|
||||
</span>
|
||||
{setup.momentum_percentile != null && (
|
||||
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
||||
residual momentum {Math.round(setup.momentum_percentile)}th %ile
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-10 text-right">
|
||||
<div>
|
||||
<p className="section-index">reward / risk</p>
|
||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||
{setup.rr_ratio.toFixed(1)}
|
||||
<span className="text-lg text-gray-400"> : 1</span>
|
||||
</p>
|
||||
</div>
|
||||
{prob != null && (
|
||||
<div>
|
||||
<p className="section-index">target probability</p>
|
||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||
{Math.round(prob)}
|
||||
<span className="text-lg text-gray-400">%</span>
|
||||
</p>
|
||||
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
|
||||
<span className="block h-full rounded-full bg-blue-500" style={{ width: `${Math.round(prob)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PriceRail
|
||||
direction={setup.direction}
|
||||
entry={setup.entry_price}
|
||||
stop={setup.stop_loss}
|
||||
target={setup.target}
|
||||
current={setup.current_price}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
|
||||
<span className="text-xs text-gray-500">cleared the gate · {gateNote}</span>
|
||||
<span className="flex gap-2.5">
|
||||
<Link
|
||||
to={`/ticker/${setup.symbol}`}
|
||||
className="rounded-lg border border-blue-500/35 bg-blue-500/15 px-4 py-1.5 text-[13px] font-semibold text-blue-300 transition-colors hover:bg-blue-500/25"
|
||||
>
|
||||
Ticker details
|
||||
</Link>
|
||||
<Link
|
||||
to="/signals"
|
||||
className="rounded-lg border border-white/[0.09] px-4 py-1.5 text-[13px] font-semibold text-gray-400 transition-colors hover:bg-white/[0.04] hover:text-gray-200"
|
||||
>
|
||||
All setups
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -101,11 +183,28 @@ export default function DashboardPage() {
|
||||
.slice(0, 5);
|
||||
}, [qualifiedSetups]);
|
||||
|
||||
// Radar: every live setup ranked by the same score, with the gate verdict.
|
||||
const radar = useMemo(() => {
|
||||
if (!activation.data) return [];
|
||||
return [...(trades.data ?? [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.strategy_rank ?? b.momentum_percentile ?? -Infinity) -
|
||||
(a.strategy_rank ?? a.momentum_percentile ?? -Infinity),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((setup, i) => ({
|
||||
setup,
|
||||
rank: i + 1,
|
||||
reason: disqualifyReason(setup, activation.data!),
|
||||
}));
|
||||
}, [trades.data, activation.data]);
|
||||
|
||||
const topWatchlist = useMemo(
|
||||
() =>
|
||||
[...(watchlist.data ?? [])]
|
||||
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
|
||||
.slice(0, 10),
|
||||
.slice(0, 12),
|
||||
[watchlist.data],
|
||||
);
|
||||
|
||||
@@ -131,14 +230,37 @@ export default function DashboardPage() {
|
||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
|
||||
}, [openTrades.data]);
|
||||
|
||||
const liveCount = trades.data?.length ?? 0;
|
||||
const qualifiedCount = qualifiedSetups.length;
|
||||
const verdict = !trades.data || !activation.data
|
||||
? 'Market overview'
|
||||
: qualifiedCount === 0
|
||||
? 'No setup clears the gate today.'
|
||||
: qualifiedCount === 1
|
||||
? 'One setup clears the gate today.'
|
||||
: `${qualifiedCount} setups clear the gate today.`;
|
||||
|
||||
const topPick = topSetups[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-slide-up">
|
||||
{/* Hero */}
|
||||
{/* Hero — the verdict */}
|
||||
<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 className="font-display mt-1 text-4xl font-semibold tracking-tight text-gray-100">
|
||||
{verdict}
|
||||
</h1>
|
||||
{trades.data && (
|
||||
<p className="mt-2 text-[15px] text-gray-400">
|
||||
{qualifiedCount} qualified of {liveCount} live setups · {exposure.count} open position{exposure.count === 1 ? '' : 's'}
|
||||
{exposure.rPriced > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<b className={rColor(exposure.unrealR)}>{fmtR(exposure.unrealR)}</b> unrealized
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Market regime banner */}
|
||||
@@ -158,28 +280,34 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focal setup */}
|
||||
{(trades.isLoading || activation.isLoading) && <SkeletonCard />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && activation.data && (
|
||||
topPick ? (
|
||||
<FocusCard
|
||||
setup={topPick}
|
||||
name={tickerNames.get(topPick.symbol.toUpperCase())}
|
||||
gateNote={activationSummary(activation.data)}
|
||||
/>
|
||||
) : (
|
||||
<Callout variant="empty">
|
||||
No qualified actionable setups right now — the radar below shows what's close and why it doesn't qualify.
|
||||
</Callout>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Metric strip */}
|
||||
{(trades.isLoading || openTrades.isLoading) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
<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-5">
|
||||
<Metric
|
||||
label="Live Setups"
|
||||
value={String(trades.data?.length ?? 0)}
|
||||
sub="latest per ticker & direction"
|
||||
/>
|
||||
<Metric
|
||||
label="Qualified"
|
||||
value={String(qualifiedSetups.length)}
|
||||
sub={activation.data ? activationSummary(activation.data) : 'clears the activation gate'}
|
||||
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric
|
||||
label="Open Risk"
|
||||
value={exposure.count > 0 ? `$${exposure.riskUsd.toFixed(0)}` : '—'}
|
||||
sub={exposure.count > 0 ? `${exposure.count} open · risk to stops` : 'no open trades'}
|
||||
sub={exposure.count > 0 ? `${exposure.count} open · distance to stops` : 'no open trades'}
|
||||
/>
|
||||
<Metric
|
||||
label="Unrealized"
|
||||
@@ -196,10 +324,16 @@ export default function DashboardPage() {
|
||||
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
|
||||
valueClass={
|
||||
exposure.alphaPriced > 0
|
||||
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
? exposure.alphaUsd >= 0 ? 'text-emerald-300' : 'text-red-300'
|
||||
: 'text-gray-100'
|
||||
}
|
||||
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
|
||||
sub="open trades vs buy-and-hold SPY"
|
||||
/>
|
||||
<Metric
|
||||
label="Gate"
|
||||
value={trades.data ? `${qualifiedCount} / ${liveCount}` : '—'}
|
||||
valueClass={qualifiedCount > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
sub={activation.data ? activationSummary(activation.data) : 'setups clearing the gate'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -207,142 +341,107 @@ export default function DashboardPage() {
|
||||
{/* Open paper trades */}
|
||||
<OpenTradesPanel />
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-5">
|
||||
{/* Top setups */}
|
||||
<div className="xl:col-span-3">
|
||||
<Section
|
||||
title="Top Setups"
|
||||
hint="current qualified signals"
|
||||
>
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={7} />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && topSetups.length === 0 && (
|
||||
<Callout variant="empty">No qualified actionable setups right now.</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">Current / Target</th>
|
||||
<th className="px-4 py-3 text-right">R:R</th>
|
||||
<th className="px-4 py-3 text-right">Target Prob</th>
|
||||
<th className="px-4 py-3 text-right">Residual</th>
|
||||
<th className="hidden px-4 py-3 md:table-cell">Conviction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topSetups.map((setup, i) => {
|
||||
const isTopPick = i === 0;
|
||||
const displayedPrice = setup.current_price ?? setup.entry_price;
|
||||
return (
|
||||
<tr
|
||||
key={setup.id}
|
||||
className={`border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03] ${
|
||||
isTopPick ? 'bg-blue-500/[0.06]' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/ticker/${setup.symbol}`} className="font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
{setup.symbol}
|
||||
</Link>
|
||||
{isTopPick && (
|
||||
<span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-300">
|
||||
Top pick
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[160px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
|
||||
<td className="num px-4 py-3 text-right">
|
||||
<div className="text-gray-200">{formatPrice(displayedPrice)}</div>
|
||||
<div className="text-[11px] text-gray-500">target {formatPrice(setup.target)}</div>
|
||||
</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">
|
||||
{(() => {
|
||||
const p = primaryTargetProbability(setup);
|
||||
return p != null ? `${Math.round(p)}%` : '—';
|
||||
})()}
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-400">
|
||||
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||
</td>
|
||||
<td className={`hidden px-4 py-3 text-xs font-semibold md:table-cell ${convictionClass(setup.recommended_action)}`}>
|
||||
{convictionLabel(setup.recommended_action)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex justify-end 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>
|
||||
|
||||
{/* My watchlist */}
|
||||
<div className="xl:col-span-2">
|
||||
<Section title="My Watchlist" hint="today's move">
|
||||
{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">
|
||||
Your watchlist is empty — open any ticker and tap “☆ Add to watchlist”.
|
||||
</Callout>
|
||||
)}
|
||||
{topWatchlist.length > 0 && (
|
||||
<div className="glass overflow-hidden">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{topWatchlist.map((entry) => (
|
||||
<li key={entry.symbol}>
|
||||
{/* Radar — every live setup, ranked, with the gate verdict */}
|
||||
<Section title="Radar" hint="ranked by strategy score · why each does or doesn't qualify">
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={6} />}
|
||||
{trades.data && radar.length === 0 && (
|
||||
<Callout variant="empty">No live setups right now.</Callout>
|
||||
)}
|
||||
{radar.length > 0 && (
|
||||
<div className="glass px-4 py-1">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{radar.map(({ setup, rank, reason }) => {
|
||||
const qualified = reason === null;
|
||||
const prob = primaryTargetProbability(setup);
|
||||
return (
|
||||
<li
|
||||
key={setup.id}
|
||||
className={`grid grid-cols-[20px_130px_1fr_100px] items-center gap-3 px-2 py-2.5 sm:grid-cols-[20px_150px_46px_1fr_54px_46px_minmax(150px,1fr)] ${
|
||||
qualified ? '' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<span className="num text-[11px] text-gray-500">{rank}</span>
|
||||
<span>
|
||||
<Link
|
||||
to={`/ticker/${entry.symbol}`}
|
||||
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
|
||||
to={`/ticker/${setup.symbol}`}
|
||||
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-gray-200">{entry.symbol}</span>
|
||||
{entry.composite_score != null && (
|
||||
<span className="num text-[10px] text-gray-500">score {entry.composite_score.toFixed(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-baseline gap-3">
|
||||
<span className="num text-sm text-gray-200">
|
||||
{entry.last_close != null ? formatPrice(entry.last_close) : '—'}
|
||||
</span>
|
||||
<span className={`num w-16 text-right text-sm font-semibold ${rColor(entry.change_pct)}`}>
|
||||
{entry.change_pct != null
|
||||
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
|
||||
: '—'}
|
||||
</span>
|
||||
</span>
|
||||
{setup.symbol}
|
||||
</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>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<span className="block max-w-[130px] truncate text-[10.5px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden sm:block"><DirectionTag direction={setup.direction} /></span>
|
||||
<span className="hidden items-center gap-2 sm:flex">
|
||||
<span className="h-1 flex-1 overflow-hidden rounded-full bg-white/[0.07]">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.round(setup.momentum_percentile ?? 0)}%`,
|
||||
background: qualified ? 'var(--up)' : 'var(--ink-3)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="num text-[10px] text-gray-500">
|
||||
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="num hidden text-right text-xs text-gray-400 sm:block">
|
||||
{setup.rr_ratio.toFixed(1)}:1
|
||||
</span>
|
||||
<span className="num hidden text-right text-xs text-gray-400 sm:block">
|
||||
{prob != null ? `${Math.round(prob)}%` : '—'}
|
||||
</span>
|
||||
<span className={`text-right text-[11.5px] ${qualified ? 'text-blue-300' : 'text-gray-500'}`}>
|
||||
{qualified ? '✓ clears the gate' : reason}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex justify-end border-t border-white/[0.04] px-2 py-2.5">
|
||||
<Link to="/signals" className="text-xs font-medium text-blue-300 transition-colors hover:text-blue-200">
|
||||
All setups →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Watchlist — compact chips, drill into any ticker */}
|
||||
<Section title="My Watchlist" hint="today's move">
|
||||
{watchlist.isLoading && <SkeletonTable rows={2} cols={4} />}
|
||||
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
|
||||
{watchlist.data && topWatchlist.length === 0 && (
|
||||
<Callout variant="empty">
|
||||
Your watchlist is empty — open any ticker and tap “☆ Add to watchlist”.
|
||||
</Callout>
|
||||
)}
|
||||
{topWatchlist.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{topWatchlist.map((entry) => (
|
||||
<Link
|
||||
key={entry.symbol}
|
||||
to={`/ticker/${entry.symbol}`}
|
||||
className="inline-flex items-baseline gap-2 rounded-full border border-white/[0.09] px-3.5 py-1.5 text-[13px] transition-colors hover:border-blue-400/40 hover:bg-white/[0.03]"
|
||||
>
|
||||
<b className="font-semibold text-gray-200">{entry.symbol}</b>
|
||||
<span className={`num text-[11.5px] ${rColor(entry.change_pct)}`}>
|
||||
{entry.change_pct != null
|
||||
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
|
||||
: '—'}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/market" className="ml-1 text-xs font-medium text-blue-300 transition-colors hover:text-blue-200">
|
||||
Full watchlist →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,680 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const executionRows = [
|
||||
{ symbol: 'NVDA', action: 'Long high', score: 91, rr: '1:3.6', entry: '$142.30', stop: '$137.10', target: '$161.20', tape: '+2.8%' },
|
||||
{ symbol: 'MSFT', action: 'Long moderate', score: 78, rr: '1:2.4', entry: '$503.10', stop: '$488.40', target: '$538.70', tape: '+0.9%' },
|
||||
{ symbol: 'TSLA', action: 'Short moderate', score: 73, rr: '1:2.1', entry: '$298.20', stop: '$309.60', target: '$274.10', tape: '-1.7%' },
|
||||
{ symbol: 'AMD', action: 'Neutral', score: 54, rr: '1:1.3', entry: '$164.60', stop: '$158.80', target: '$172.10', tape: '+0.2%' },
|
||||
];
|
||||
|
||||
const ledgerRows = [
|
||||
{ symbol: 'AVGO', thesis: 'Momentum with earnings buffer', risk: '0.48R', quality: 'A-', decision: 'Watch entry' },
|
||||
{ symbol: 'META', thesis: 'Pullback into support band', risk: '0.32R', quality: 'B+', decision: 'Size small' },
|
||||
{ symbol: 'LLY', thesis: 'High trend, weak volume follow-through', risk: '0.71R', quality: 'B', decision: 'Wait' },
|
||||
];
|
||||
|
||||
const radarSignals = [
|
||||
{ symbol: 'CRWD', lane: 'Breakout', score: 88, delta: '+4.1R' },
|
||||
{ symbol: 'SHOP', lane: 'Retest', score: 82, delta: '+2.7R' },
|
||||
{ symbol: 'NFLX', lane: 'Fade', score: 75, delta: '+1.9R' },
|
||||
];
|
||||
|
||||
const structureLevels = [
|
||||
{ label: 'Support', price: '137.10', note: 'invalidates below', tone: 'danger' },
|
||||
{ label: 'Entry', price: '142.30', note: 'active trigger', tone: 'focus' },
|
||||
{ label: 'Target', price: '161.20', note: '+13.3% upside', tone: 'success' },
|
||||
] as const;
|
||||
|
||||
const mockCandles = [
|
||||
{ x: 24, open: 142, close: 149, high: 154, low: 138 },
|
||||
{ x: 48, open: 149, close: 144, high: 151, low: 141 },
|
||||
{ x: 72, open: 144, close: 152, high: 156, low: 143 },
|
||||
{ x: 96, open: 152, close: 158, high: 162, low: 150 },
|
||||
{ x: 120, open: 158, close: 155, high: 164, low: 153 },
|
||||
{ x: 144, open: 155, close: 166, high: 169, low: 154 },
|
||||
{ x: 168, open: 166, close: 171, high: 176, low: 164 },
|
||||
{ x: 192, open: 171, close: 168, high: 173, low: 165 },
|
||||
{ x: 216, open: 168, close: 178, high: 181, low: 167 },
|
||||
{ x: 240, open: 178, close: 184, high: 188, low: 176 },
|
||||
{ x: 264, open: 184, close: 181, high: 187, low: 179 },
|
||||
{ x: 288, open: 181, close: 191, high: 196, low: 180 },
|
||||
{ x: 312, open: 191, close: 198, high: 203, low: 189 },
|
||||
{ x: 336, open: 198, close: 195, high: 201, low: 192 },
|
||||
{ x: 360, open: 195, close: 207, high: 211, low: 193 },
|
||||
{ x: 384, open: 207, close: 214, high: 218, low: 204 },
|
||||
];
|
||||
|
||||
function MiniChart({ color = '#8bd6de' }: { color?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 360 120" className="h-28 w-full" role="img" aria-label="Mock price chart">
|
||||
<defs>
|
||||
<linearGradient id={`fill-${color.replace('#', '')}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M0 88 L26 82 L52 91 L78 70 L104 72 L130 48 L156 55 L182 36 L208 42 L234 29 L260 34 L286 18 L312 27 L338 15 L360 22 L360 120 L0 120 Z"
|
||||
fill={`url(#fill-${color.replace('#', '')})`}
|
||||
/>
|
||||
<path
|
||||
d="M0 88 L26 82 L52 91 L78 70 L104 72 L130 48 L156 55 L182 36 L208 42 L234 29 L260 34 L286 18 L312 27 L338 15 L360 22"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path d="M0 72 H360 M0 40 H360" stroke="currentColor" strokeDasharray="4 8" strokeOpacity="0.18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StructureChip({ level }: { level: (typeof structureLevels)[number] }) {
|
||||
const styles = {
|
||||
danger: {
|
||||
box: 'border-[#d86148]/45 bg-[#24100f]',
|
||||
label: 'text-[#ff9b82]',
|
||||
price: 'text-[#ffd5c7]',
|
||||
line: 'from-[#d86148] to-transparent',
|
||||
},
|
||||
focus: {
|
||||
box: 'border-[#8bd6de]/60 bg-[#0d2028] shadow-[0_0_28px_rgba(139,214,222,0.14)]',
|
||||
label: 'text-[#a9f1f5]',
|
||||
price: 'text-white',
|
||||
line: 'from-[#8bd6de] to-transparent',
|
||||
},
|
||||
success: {
|
||||
box: 'border-[#f0b46a]/45 bg-[#281b12]',
|
||||
label: 'text-[#ffc985]',
|
||||
price: 'text-[#ffe6bd]',
|
||||
line: 'from-[#f0b46a] to-transparent',
|
||||
},
|
||||
}[level.tone];
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-lg border p-3 text-left ${styles.box}`}>
|
||||
<div className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${styles.line}`} />
|
||||
<p className={`font-mono text-[10px] uppercase tracking-[0.18em] ${styles.label}`}>{level.label}</p>
|
||||
<p className={`num mt-1 text-lg font-semibold ${styles.price}`}>${level.price}</p>
|
||||
<p className="mt-1 text-[10px] text-[#9d94aa]">{level.note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionIntro({
|
||||
eyebrow,
|
||||
title,
|
||||
body,
|
||||
bestFor,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
body: string;
|
||||
bestFor: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto mb-5 flex max-w-7xl flex-col gap-3 px-4 sm:px-6 lg:px-8">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.26em] text-gray-500">{eyebrow}</p>
|
||||
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-end">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-4xl">{title}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-gray-400">{body}</p>
|
||||
</div>
|
||||
<p className="max-w-sm rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3 text-xs leading-5 text-gray-300">
|
||||
<span className="font-mono uppercase tracking-[0.18em] text-gray-500">Best for</span>
|
||||
<br />
|
||||
{bestFor}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionDeskMockup() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[#05050b] py-10 text-[#f7f0e8]">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-70"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at 82% 18%, rgba(205,83,55,0.32), transparent 18%), radial-gradient(circle at 88% 24%, rgba(255,166,105,0.18), transparent 10%), radial-gradient(circle at 14% 0%, rgba(105,120,177,0.14), transparent 26%), radial-gradient(circle at 20% 36%, rgba(255,255,255,0.10) 0 1px, transparent 1.5px), radial-gradient(circle at 64% 22%, rgba(255,255,255,0.09) 0 1px, transparent 1.5px), radial-gradient(circle at 78% 66%, rgba(255,255,255,0.10) 0 1px, transparent 1.5px), linear-gradient(180deg, #070711 0%, #05050b 58%, #09070a 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute -right-32 top-10 h-80 w-80 rounded-full bg-[radial-gradient(circle_at_34%_32%,#ffb077_0%,#c65a3d_35%,#4b1b1c_68%,transparent_72%)] opacity-45 blur-[1px]" />
|
||||
<div className="pointer-events-none absolute right-16 top-24 h-64 w-64 rounded-full border border-[#ff9a62]/10" />
|
||||
<div className="relative">
|
||||
<SectionIntro
|
||||
eyebrow="Direction 01 / Execution deck"
|
||||
title="Orbital trading deck with a Mars-warm focal glow"
|
||||
body="Still a dense workstation, but the atmosphere shifts from green terminal to deep black-violet panels, rust-red warmth, sparse stars, and cool cyan only for live instrumentation."
|
||||
bestFor="Power users who want speed and confidence, with a more premium, less neon visual language."
|
||||
/>
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-7xl gap-4 px-4 sm:px-6 lg:grid-cols-[220px_minmax(0,1fr)_300px] lg:px-8">
|
||||
<aside className="rounded-lg border border-[#2c2334] bg-[#0b0a12]/90 p-4 shadow-[0_22px_70px_rgba(0,0,0,0.38)]">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-[#8bd6de] shadow-[0_0_16px_rgba(139,214,222,0.75)]" />
|
||||
<span className="font-mono text-xs uppercase tracking-[0.22em] text-[#9f96ad]">Signal live</span>
|
||||
</div>
|
||||
{['Overview', 'Market', 'Signals', 'Regime', 'Admin'].map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className={`mb-1 flex items-center justify-between rounded-md px-3 py-2 text-sm ${
|
||||
item === 'Signals'
|
||||
? 'border border-[#c85d42]/25 bg-[#241416] text-[#ffd6c8]'
|
||||
: 'border border-transparent text-[#8c8498] hover:bg-white/[0.035]'
|
||||
}`}
|
||||
>
|
||||
<span>{item}</span>
|
||||
<span className="font-mono text-[10px]">0{index + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-8 border-t border-[#2c2334] pt-4">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-[#71697d]">Scanner</p>
|
||||
<div className="mt-3 h-2 rounded-full bg-[#17131d]">
|
||||
<div className="h-2 w-3/4 rounded-full bg-gradient-to-r from-[#9f3f32] via-[#d96449] to-[#f3b36f] shadow-[0_0_18px_rgba(217,100,73,0.28)]" />
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-xs text-[#cdb4a7]">73% complete</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="overflow-hidden rounded-lg border border-[#33253a] bg-[#0b0b13]/95 shadow-[0_24px_90px_rgba(0,0,0,0.58)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#2c2334] bg-[#101019]/80 px-5 py-4">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#81778e]">Signals / Today</p>
|
||||
<h3 className="mt-1 font-display text-2xl font-semibold text-[#fff8ef]">Qualified setups</h3>
|
||||
</div>
|
||||
<button className="rounded-md border border-[#ff9a62]/40 bg-[#d96449] px-4 py-2 text-sm font-semibold text-[#160808] shadow-[0_0_24px_rgba(217,100,73,0.30)]">
|
||||
Run scanner
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-0 border-b border-[#2c2334] md:grid-cols-4">
|
||||
{[
|
||||
['Active', '18'],
|
||||
['Avg R:R', '2.42'],
|
||||
['High conviction', '6'],
|
||||
['Drift check', '+0.31R'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="border-b border-r border-[#2c2334] p-4 md:border-b-0">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-[#81778e]">{label}</p>
|
||||
<p className="num mt-2 text-2xl font-semibold text-[#fff8ef]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="font-mono text-[10px] uppercase tracking-[0.18em] text-[#81778e]">
|
||||
<tr className="border-b border-[#2c2334]">
|
||||
{['Symbol', 'Action', 'Score', 'R:R', 'Entry', 'Stop', 'Target', 'Tape'].map((heading) => (
|
||||
<th key={heading} className="px-4 py-3 font-medium">{heading}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{executionRows.map((row) => (
|
||||
<tr key={row.symbol} className="border-b border-[#1c1723] text-[#d8d1dc] hover:bg-[#17111b]/70">
|
||||
<td className="px-4 py-4 font-semibold text-[#fff8ef]">{row.symbol}</td>
|
||||
<td className="px-4 py-4 text-[#8bd6de]">{row.action}</td>
|
||||
<td className="num px-4 py-4">{row.score}</td>
|
||||
<td className="num px-4 py-4 text-[#f0b46a]">{row.rr}</td>
|
||||
<td className="num px-4 py-4">{row.entry}</td>
|
||||
<td className="num px-4 py-4 text-[#ff9b82]">{row.stop}</td>
|
||||
<td className="num px-4 py-4 text-[#ffc985]">{row.target}</td>
|
||||
<td className="num px-4 py-4">{row.tape}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="relative overflow-hidden rounded-lg border border-[#33253a] bg-[#0b0a12]/95 p-4">
|
||||
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-[radial-gradient(circle,#d96449_0%,rgba(217,100,73,0.18)_42%,transparent_68%)] opacity-70" />
|
||||
<div className="relative flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-[#81778e]">NVDA structure</p>
|
||||
<h4 className="mt-1 font-display text-xl font-semibold text-[#fff8ef]">Entry map</h4>
|
||||
</div>
|
||||
<span className="rounded-full border border-[#8bd6de]/30 bg-[#8bd6de]/10 px-2.5 py-1 font-mono text-[10px] text-[#b9f8ff]">
|
||||
live
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<MiniChart />
|
||||
<div className="pointer-events-none absolute left-[38%] top-[18%] h-[58%] w-px bg-gradient-to-b from-transparent via-[#8bd6de] to-transparent shadow-[0_0_18px_rgba(139,214,222,0.62)]" />
|
||||
<div className="pointer-events-none absolute left-[38%] top-[14%] rounded-full border border-[#8bd6de]/50 bg-[#0b0a12] px-2 py-1 font-mono text-[10px] text-[#d5fbff]">
|
||||
trigger
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{structureLevels.map((level) => (
|
||||
<StructureChip key={level.label} level={level} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#33253a] bg-[#0b0a12]/95 p-4">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-[#81778e]">Decision note</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#d8d1dc]">
|
||||
Prioritize setups with clean stop distance, fresh support confirmation, and no regime warning.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerMockup() {
|
||||
return (
|
||||
<section className="bg-[#eef5f1] py-10 text-[#18231f]">
|
||||
<div className="mx-auto mb-5 flex max-w-7xl flex-col gap-3 px-4 sm:px-6 lg:px-8">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.26em] text-[#64756e]">Direction 02 / Risk ledger</p>
|
||||
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-end">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="font-display text-3xl font-semibold tracking-tight text-[#18231f] sm:text-4xl">Calm, research-led risk journal</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-[#5d6d66]">
|
||||
This direction treats signals as decisions to underwrite. It puts sizing, evidence, and trade quality ahead of spectacle.
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-sm rounded-lg border border-[#c7d7cf] bg-white/60 px-4 py-3 text-xs leading-5 text-[#33423c]">
|
||||
<span className="font-mono uppercase tracking-[0.18em] text-[#718078]">Best for</span>
|
||||
<br />
|
||||
A slower, more trustworthy product posture with less adrenaline and more accountability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto grid max-w-7xl gap-4 px-4 sm:px-6 lg:grid-cols-[1.15fr_0.85fr] lg:px-8">
|
||||
<main className="rounded-lg border border-[#c7d7cf] bg-[#fbfdfb] p-5 shadow-[0_20px_70px_rgba(23,48,39,0.10)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-[#dbe6e0] pb-5">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-[#718078]">SignalPlatform</p>
|
||||
<h3 className="mt-2 font-display text-4xl font-semibold text-[#18231f]">Today's risk book</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
{[
|
||||
['Budget used', '42%'],
|
||||
['Open R', '+3.8'],
|
||||
['Max new risk', '0.6R'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-md border border-[#dbe6e0] bg-[#f4faf7] px-3 py-2">
|
||||
<p className="num text-lg font-semibold text-[#1d4f43]">{value}</p>
|
||||
<p className="text-[10px] text-[#718078]">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-lg border border-[#dbe6e0]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[#f1f7f4] text-[11px] uppercase tracking-[0.14em] text-[#718078]">
|
||||
<tr>
|
||||
{['Ticker', 'Thesis', 'Risk', 'Quality', 'Decision'].map((heading) => (
|
||||
<th key={heading} className="px-4 py-3 font-semibold">{heading}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ledgerRows.map((row) => (
|
||||
<tr key={row.symbol} className="border-t border-[#dbe6e0]">
|
||||
<td className="px-4 py-4 font-semibold text-[#153f35]">{row.symbol}</td>
|
||||
<td className="px-4 py-4 text-[#40514a]">{row.thesis}</td>
|
||||
<td className="num px-4 py-4 text-[#9a3b4f]">{row.risk}</td>
|
||||
<td className="px-4 py-4">{row.quality}</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className="rounded-full bg-[#dcece5] px-3 py-1 text-xs font-medium text-[#214f44]">{row.decision}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
<aside className="grid gap-4">
|
||||
<div className="rounded-lg border border-[#c7d7cf] bg-[#fbfdfb] p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-[#718078]">Evidence stack</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{['Market regime supports longs', 'Sentiment positive but cooling', 'Support zone tested twice', 'Earnings window clear'].map((item) => (
|
||||
<div key={item} className="flex items-center gap-3 rounded-md bg-[#f1f7f4] p-3 text-sm text-[#33423c]">
|
||||
<span className="h-2 w-2 rounded-full bg-[#1f8a70]" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#c7d7cf] bg-[#18231f] p-5 text-[#eef5f1]">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-[#9fb5ac]">Primary decision</p>
|
||||
<p className="mt-3 text-3xl font-semibold">Let quality decide size.</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#c7d7cf]">
|
||||
The signature element is a risk budget strip that makes every trade feel accountable before it feels exciting.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RadarMockup() {
|
||||
return (
|
||||
<section className="bg-[#08090d] py-10 text-white">
|
||||
<SectionIntro
|
||||
eyebrow="Direction 03 / Signal radar"
|
||||
title="Visual triage for what needs attention now"
|
||||
body="The page is built around a radar map: signals are grouped by setup type, conviction, and urgency before the user ever opens a table."
|
||||
bestFor="A more distinctive product identity where the scanner is the hero and tables become secondary detail."
|
||||
/>
|
||||
<div className="mx-auto grid max-w-7xl gap-4 px-4 sm:px-6 lg:grid-cols-[minmax(0,1fr)_360px] lg:px-8">
|
||||
<main className="relative min-h-[560px] overflow-hidden rounded-lg border border-[#2b3040] bg-[#10121a] p-5">
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[680px] w-[680px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-80"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, rgba(75, 231, 208, 0.10) 0 2px, transparent 3px 100%), repeating-radial-gradient(circle, rgba(255,255,255,0.12) 0 1px, transparent 1px 110px), conic-gradient(from 18deg, rgba(75,231,208,0.35), rgba(255,90,139,0.10), rgba(244,208,111,0.22), rgba(75,231,208,0.35))',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#7a8298]">Scanner field</p>
|
||||
<h3 className="mt-1 font-display text-4xl font-semibold">Signal radar</h3>
|
||||
</div>
|
||||
<div className="rounded-full border border-[#4be7d0]/30 bg-[#4be7d0]/10 px-4 py-2 font-mono text-xs text-[#90fff0]">
|
||||
32 live candidates
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
['Breakout', 'left-[18%] top-[34%]', '#4be7d0'],
|
||||
['Retest', 'left-[58%] top-[22%]', '#f4d06f'],
|
||||
['Fade', 'left-[68%] top-[62%]', '#ff5a8b'],
|
||||
['Neutral', 'left-[30%] top-[70%]', '#9aa4bd'],
|
||||
].map(([label, position, color]) => (
|
||||
<div key={label} className={`absolute ${position} z-10`}>
|
||||
<div className="rounded-full border bg-[#10121a]/90 px-3 py-2 text-xs font-semibold" style={{ borderColor: color, color }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute bottom-5 left-5 right-5 z-10 grid gap-3 md:grid-cols-3">
|
||||
{radarSignals.map((signal) => (
|
||||
<div key={signal.symbol} className="rounded-lg border border-white/10 bg-[#08090d]/80 p-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display text-2xl font-semibold">{signal.symbol}</span>
|
||||
<span className="font-mono text-xs text-[#4be7d0]">{signal.delta}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#9aa4bd]">{signal.lane}</p>
|
||||
<div className="mt-4 h-1.5 rounded-full bg-white/10">
|
||||
<div className="h-1.5 rounded-full bg-[#4be7d0]" style={{ width: `${signal.score}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[10px] text-[#7a8298]">Conviction {signal.score}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-lg border border-[#2b3040] bg-[#10121a] p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#7a8298]">Triage filters</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
{['Qualified', 'R:R > 2', 'Regime ok', 'Fresh only'].map((item) => (
|
||||
<button key={item} className="rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-[#dfe6ff]">{item}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#2b3040] bg-[#10121a] p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#7a8298]">Queue</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{radarSignals.map((signal, index) => (
|
||||
<div key={signal.symbol} className="flex items-center justify-between rounded-md bg-white/[0.04] p-3">
|
||||
<div>
|
||||
<p className="font-semibold">{signal.symbol}</p>
|
||||
<p className="text-xs text-[#9aa4bd]">{signal.lane}</p>
|
||||
</div>
|
||||
<span className="num text-lg text-[#4be7d0]">{index + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#ff5a8b]/25 bg-[#ff5a8b]/10 p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#ff9ab9]">Design risk</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#ffd5e2]">
|
||||
Most memorable, but it needs careful interaction design so the radar stays useful rather than decorative.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MockCandlestickChart() {
|
||||
const minPrice = 134;
|
||||
const maxPrice = 222;
|
||||
const chartTop = 28;
|
||||
const chartHeight = 284;
|
||||
const y = (price: number) => chartTop + ((maxPrice - price) / (maxPrice - minPrice)) * chartHeight;
|
||||
const candleWidth = 12;
|
||||
|
||||
const levels = [
|
||||
{ label: 'Target', price: 214, color: '#f4d06f', dash: '8 5' },
|
||||
{ label: 'Entry', price: 184, color: '#66e7c6', dash: '0' },
|
||||
{ label: 'Stop', price: 165, color: '#ff7777', dash: '5 4' },
|
||||
{ label: 'Support', price: 158, color: '#ff9f9f', dash: '2 6' },
|
||||
];
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 520 360" className="h-[360px] w-full" role="img" aria-label="Ticker detail candlestick mockup">
|
||||
<defs>
|
||||
<linearGradient id="ticker-reward-zone" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f4d06f" stopOpacity="0.26" />
|
||||
<stop offset="100%" stopColor="#66e7c6" stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ticker-risk-zone" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#66e7c6" stopOpacity="0.04" />
|
||||
<stop offset="100%" stopColor="#ff7777" stopOpacity="0.24" />
|
||||
</linearGradient>
|
||||
<filter id="soft-glow">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width="520" height="360" rx="14" fill="#07110f" />
|
||||
{[0, 1, 2, 3, 4].map((tick) => (
|
||||
<line key={tick} x1="18" x2="472" y1={52 + tick * 58} y2={52 + tick * 58} stroke="#15352f" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
<rect
|
||||
x="18"
|
||||
y={y(214)}
|
||||
width="454"
|
||||
height={Math.max(y(184) - y(214), 1)}
|
||||
fill="url(#ticker-reward-zone)"
|
||||
/>
|
||||
<rect
|
||||
x="18"
|
||||
y={y(184)}
|
||||
width="454"
|
||||
height={Math.max(y(165) - y(184), 1)}
|
||||
fill="url(#ticker-risk-zone)"
|
||||
/>
|
||||
|
||||
{levels.map((level) => (
|
||||
<g key={level.label}>
|
||||
<line
|
||||
x1="18"
|
||||
x2="472"
|
||||
y1={y(level.price)}
|
||||
y2={y(level.price)}
|
||||
stroke={level.color}
|
||||
strokeDasharray={level.dash}
|
||||
strokeWidth={level.label === 'Entry' ? 2 : 1.3}
|
||||
opacity={level.label === 'Entry' ? 1 : 0.76}
|
||||
/>
|
||||
<rect x="382" y={y(level.price) - 13} width="120" height="26" rx="6" fill="#07110f" stroke={level.color} opacity="0.96" />
|
||||
<text x="392" y={y(level.price) + 4} fill={level.color} fontFamily="IBM Plex Mono, monospace" fontSize="10">
|
||||
{level.label} ${level.price}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{mockCandles.map((bar) => {
|
||||
const bullish = bar.close >= bar.open;
|
||||
const color = bullish ? '#66e7c6' : '#ff7777';
|
||||
const bodyTop = Math.min(y(bar.open), y(bar.close));
|
||||
const bodyHeight = Math.max(Math.abs(y(bar.close) - y(bar.open)), 2);
|
||||
return (
|
||||
<g key={bar.x}>
|
||||
<line x1={bar.x} x2={bar.x} y1={y(bar.high)} y2={y(bar.low)} stroke={color} strokeWidth="1.6" opacity="0.92" />
|
||||
<rect
|
||||
x={bar.x - candleWidth / 2}
|
||||
y={bodyTop}
|
||||
width={candleWidth}
|
||||
height={bodyHeight}
|
||||
rx="2"
|
||||
fill={color}
|
||||
opacity={bullish ? 0.92 : 0.82}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<circle cx="240" cy={y(184)} r="5" fill="#66e7c6" filter="url(#soft-glow)" />
|
||||
<path d={`M240 ${y(184)} C280 ${y(176)}, 320 ${y(184)}, 360 ${y(166)}`} fill="none" stroke="#66e7c6" strokeWidth="1.4" strokeDasharray="4 5" opacity="0.75" />
|
||||
<text x="25" y="332" fill="#5f8f84" fontFamily="IBM Plex Mono, monospace" fontSize="10">
|
||||
1Y view - drag/zoom preserved in production chart
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerDetailMockup() {
|
||||
return (
|
||||
<section className="bg-[#030806] py-10 text-[#ecfff9]">
|
||||
<SectionIntro
|
||||
eyebrow="Direction 04 / Ticker detail"
|
||||
title="Candles first, setup decision second"
|
||||
body="A ticker page should make the chart explain the trade: the reward zone, risk zone, trigger and invalidation are visible before the supporting panels."
|
||||
bestFor="The actual ticker screen where a user decides whether the scanner's setup deserves attention."
|
||||
/>
|
||||
<div className="mx-auto grid max-w-7xl gap-4 px-4 sm:px-6 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-8">
|
||||
<main className="overflow-hidden rounded-lg border border-[#1d463f] bg-[#081310] shadow-[0_24px_90px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-[#15352f] p-5">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="font-display text-4xl font-semibold text-[#f4fffb]">NVDA</h3>
|
||||
<span className="rounded-full border border-[#3ef1bd]/30 bg-[#3ef1bd]/10 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.18em] text-[#8dffe8]">
|
||||
Long high
|
||||
</span>
|
||||
<span className="rounded-full border border-[#f4d06f]/30 bg-[#f4d06f]/10 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.18em] text-[#ffe79d]">
|
||||
Top pick
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[#7ea79d]">NVIDIA Corp - momentum breakout with clean support shelf</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-right">
|
||||
{[
|
||||
['Last', '$207.40', 'text-[#f4fffb]'],
|
||||
['Day', '+2.8%', 'text-[#66e7c6]'],
|
||||
['R:R', '1:3.6', 'text-[#f4d06f]'],
|
||||
].map(([label, value, color]) => (
|
||||
<div key={label} className="rounded-lg border border-[#15352f] bg-[#07110f] px-3 py-2">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-[#5f8f84]">{label}</p>
|
||||
<p className={`num mt-1 text-lg font-semibold ${color}`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{['1M', '3M', '6M', '1Y', 'All'].map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
className={`rounded px-2.5 py-1 font-mono text-[11px] ${
|
||||
range === '1Y' ? 'bg-[#3ef1bd]/15 text-[#8dffe8]' : 'text-[#5f8f84] hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-[#5f8f84]">
|
||||
overlay: auto setup
|
||||
</p>
|
||||
</div>
|
||||
<MockCandlestickChart />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-lg border border-[#3ef1bd]/35 bg-[#07110f] p-5 shadow-[0_0_45px_rgba(62,241,189,0.08)]">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#5f8f84]">Trade ticket</p>
|
||||
<h4 className="mt-2 font-display text-2xl font-semibold text-[#f4fffb]">Take the breakout only above entry.</h4>
|
||||
<div className="mt-5 space-y-3">
|
||||
{structureLevels.map((level) => (
|
||||
<StructureChip key={level.label} level={level} />
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-5 w-full rounded-md border border-[#4adfba]/40 bg-[#2dd4aa] px-4 py-3 text-sm font-semibold text-[#03100d] shadow-[0_0_24px_rgba(45,212,170,0.28)]">
|
||||
Add paper trade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#15352f] bg-[#07110f] p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#5f8f84]">Why it qualifies</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{[
|
||||
['Momentum', '92nd percentile', '#66e7c6'],
|
||||
['Composite', '88 / 100', '#f4d06f'],
|
||||
['Regime', 'Risk-on confirmed', '#66e7c6'],
|
||||
['Sentiment', 'Positive, not crowded', '#9bd8cb'],
|
||||
].map(([label, value, color]) => (
|
||||
<div key={label} className="flex items-center justify-between border-b border-[#15352f] pb-3 last:border-0 last:pb-0">
|
||||
<span className="text-sm text-[#7ea79d]">{label}</span>
|
||||
<span className="font-mono text-xs" style={{ color }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#15352f] bg-[#07110f] p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-[#5f8f84]">Polish note</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#cfeee6]">
|
||||
The tiny distinction matters: Entry is the action, target is the reward, support is the line that says when the idea is wrong.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesignMockupsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#08090d] font-sans">
|
||||
<header className="border-b border-white/10 bg-[#0d100f] px-4 py-5 text-white sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.26em] text-gray-500">Design exploration</p>
|
||||
<h1 className="mt-1 font-display text-3xl font-semibold tracking-tight">Signal page mockups</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-gray-400">
|
||||
Three different product philosophies for the trading signal experience. Static mockups, built for comparison before production adoption.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/signals"
|
||||
className="w-fit rounded-md border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-white/[0.08]"
|
||||
>
|
||||
Back to signals
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<ExecutionDeskMockup />
|
||||
<LedgerMockup />
|
||||
<RadarMockup />
|
||||
<TickerDetailMockup />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type Opportunity = {
|
||||
symbol: string;
|
||||
company: string;
|
||||
direction: 'Long' | 'Short';
|
||||
confidence: number;
|
||||
change: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
const opportunities: Opportunity[] = [
|
||||
{ symbol: 'AAPL', company: 'Apple Inc.', direction: 'Long', confidence: 82, change: '+1.8%', detail: 'Clearing a three-week base with volume support.' },
|
||||
{ symbol: 'LLY', company: 'Eli Lilly', direction: 'Long', confidence: 76, change: '+0.9%', detail: 'Holding above the 20-day trend after a quiet pullback.' },
|
||||
{ symbol: 'NVDA', company: 'NVIDIA', direction: 'Long', confidence: 71, change: '+2.3%', detail: 'Strong relative momentum; entry is extended from support.' },
|
||||
];
|
||||
|
||||
function ArrowUpRight() {
|
||||
return <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 15 15 5M7 5h8v8" /></svg>;
|
||||
}
|
||||
|
||||
function ChevronRight() {
|
||||
return <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 4 6 6-6 6" /></svg>;
|
||||
}
|
||||
|
||||
function Close() {
|
||||
return <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15" /></svg>;
|
||||
}
|
||||
|
||||
function PricePath() {
|
||||
return (
|
||||
<svg className="orbit-price-path" viewBox="0 0 680 205" preserveAspectRatio="none" aria-label="Illustrative AAPL price trend" role="img">
|
||||
<defs>
|
||||
<linearGradient id="orbit-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#e55d45" stopOpacity=".3" />
|
||||
<stop offset="100%" stopColor="#e55d45" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="orbit-line-glow"><feGaussianBlur stdDeviation="3" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
|
||||
</defs>
|
||||
<path className="orbit-chart-grid" d="M0 42H680M0 102H680M0 162H680" />
|
||||
<path className="orbit-chart-fill" d="M0 170C36 160 59 130 84 140S123 153 148 132s45-42 68-33 39 38 66 24 36-63 65-53 38 37 65 27 40-39 66-18 34 43 62 25 47-61 75-48 43 31 65 9v142H0Z" />
|
||||
<path className="orbit-chart-line" d="M0 170C36 160 59 130 84 140S123 153 148 132s45-42 68-33 39 38 66 24 36-63 65-53 38 37 65 27 40-39 66-18 34 43 62 25 47-61 75-48 43 31 65 9" />
|
||||
<circle cx="680" cy="63" r="5" className="orbit-chart-dot" filter="url(#orbit-line-glow)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesignOrbitPage() {
|
||||
const [selected, setSelected] = useState<Opportunity>(opportunities[0]);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const openDetail = (opportunity: Opportunity) => {
|
||||
setSelected(opportunity);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="orbit-page">
|
||||
<style>{orbitStyles}</style>
|
||||
<div className="orbit-stars" aria-hidden="true" />
|
||||
<aside className="orbit-nav" aria-label="Primary navigation">
|
||||
<a className="orbit-mark" href="#overview" aria-label="Orbit home"><span />orbit</a>
|
||||
<nav>
|
||||
<a className="is-current" href="#overview"><i>01</i>Overview</a>
|
||||
<a href="#opportunities"><i>02</i>Opportunities <b>3</b></a>
|
||||
<a href="#positions"><i>03</i>Positions</a>
|
||||
<a href="#watchlist"><i>04</i>Watchlist</a>
|
||||
</nav>
|
||||
<div className="orbit-nav-foot">
|
||||
<span className="orbit-avatar">DN</span>
|
||||
<div><strong>Dennis</strong><small>Personal account</small></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="orbit-content" id="overview">
|
||||
<header className="orbit-topbar">
|
||||
<div className="orbit-breadcrumb"><span className="orbit-live-dot" />Live market <em>·</em> Wed, May 22 <em>·</em> 10:42 ET</div>
|
||||
<div className="orbit-top-actions"><button className="orbit-icon-button" aria-label="Search">⌘ K</button><button className="orbit-profile">DN</button></div>
|
||||
</header>
|
||||
|
||||
<div className="orbit-intro">
|
||||
<div><p>Good morning, Dennis</p><h1>Make one clear move.</h1></div>
|
||||
<button className="orbit-quiet-button" onClick={() => openDetail(opportunities[0])}>Review today's brief <ArrowUpRight /></button>
|
||||
</div>
|
||||
|
||||
<section className="orbit-status" aria-label="Market permission">
|
||||
<div className="orbit-status-orb" aria-hidden="true"><span /></div>
|
||||
<div className="orbit-status-copy"><p>Market permission</p><h2>Constructive. New longs are in play.</h2><span>Trend and breadth are aligned. Keep position risk normal.</span></div>
|
||||
<div className="orbit-regime"><span>Regime</span><strong>Risk on</strong><small>72 / 100</small></div>
|
||||
<button className="orbit-round-button" onClick={() => openDetail(opportunities[0])} aria-label="View market detail"><ChevronRight /></button>
|
||||
</section>
|
||||
|
||||
<div className="orbit-layout">
|
||||
<section className="orbit-primary-card" aria-labelledby="focus-title">
|
||||
<div className="orbit-card-head"><div><p className="orbit-overline">Focus now</p><h2 id="focus-title">AAPL <span>Apple Inc.</span></h2></div><button className="orbit-confidence" onClick={() => openDetail(opportunities[0])}><b>82</b><span>conviction</span></button></div>
|
||||
<div className="orbit-price-row"><div><span>Last price</span><strong>$190.90</strong><small>+1.8% today</small></div><div className="orbit-call"><span>Suggested action</span><b>Enter on strength above $191.25</b></div></div>
|
||||
<div className="orbit-chart-wrap"><PricePath /><div className="orbit-chart-label left">May 1</div><div className="orbit-chart-label mid">May 10</div><div className="orbit-chart-label right">Today</div><div className="orbit-target"><span>Target</span><b>$202.00</b></div></div>
|
||||
<div className="orbit-metrics"><div><span>Risk to stop</span><strong>1.9%</strong></div><div><span>Reward / risk</span><strong>2.7<span>x</span></strong></div><div><span>Room to target</span><strong>5.8%</strong></div><button onClick={() => openDetail(opportunities[0])}>See setup <ChevronRight /></button></div>
|
||||
</section>
|
||||
|
||||
<aside className="orbit-side-stack">
|
||||
<section className="orbit-positions" id="positions"><div className="orbit-section-title"><div><p className="orbit-overline">Your exposure</p><h2>Two positions, calm risk.</h2></div><button onClick={() => openDetail(opportunities[1])}>View all <ChevronRight /></button></div><div className="orbit-position-meter"><div className="orbit-meter-track"><span style={{ width: '38%' }} /></div><div><b>38%</b><span>of risk budget in use</span></div></div><div className="orbit-position-values"><div><span>Open P&L</span><b className="positive">+$428.60</b></div><div><span>Risk to stops</span><b>$316.00</b></div></div></section>
|
||||
<section className="orbit-watch" id="watchlist"><div><p className="orbit-overline">Watchlist pulse</p><h2>Three names are moving.</h2></div><div className="orbit-watch-names"><button onClick={() => openDetail(opportunities[2])}><b>NVDA</b><span>+2.3%</span></button><button onClick={() => openDetail(opportunities[1])}><b>LLY</b><span>+0.9%</span></button><button onClick={() => openDetail(opportunities[0])}><b>MSFT</b><span>+0.7%</span></button></div></section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className="orbit-opportunities" id="opportunities">
|
||||
<div className="orbit-section-title"><div><p className="orbit-overline">Worth a look</p><h2>Opportunities, in order.</h2><span>Only setups that meet your activation rules appear here.</span></div><button onClick={() => setDetailOpen(true)}>Open scanner <ArrowUpRight /></button></div>
|
||||
<div className="orbit-opportunity-grid">
|
||||
{opportunities.map((item, index) => <button className="orbit-opportunity" key={item.symbol} onClick={() => openDetail(item)}><span className="orbit-rank">0{index + 1}</span><div className="orbit-opportunity-name"><strong>{item.symbol}</strong><span>{item.company}</span></div><div className="orbit-signal"><i className={item.direction === 'Long' ? 'long' : 'short'} />{item.direction}</div><div className="orbit-opportunity-score"><b>{item.confidence}</b><span>conviction</span></div><ChevronRight /></button>)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{detailOpen && <div className="orbit-drawer-backdrop" onMouseDown={() => setDetailOpen(false)}><aside className="orbit-drawer" aria-label={`${selected.symbol} setup details`} onMouseDown={(event) => event.stopPropagation()}><button className="orbit-drawer-close" onClick={() => setDetailOpen(false)} aria-label="Close details"><Close /></button><p className="orbit-overline">Setup detail</p><h2>{selected.symbol}<span>{selected.company}</span></h2><div className="orbit-drawer-price">$190.90 <b>{selected.change}</b></div><div className="orbit-drawer-action"><span>Suggested action</span><strong>{selected.direction === 'Long' ? 'Enter on a break above $191.25' : 'Wait for a loss of $189.20'}</strong></div><dl><div><dt>Conviction</dt><dd>{selected.confidence} / 100</dd></div><div><dt>Risk to stop</dt><dd>1.9%</dd></div><div><dt>Target</dt><dd>$202.00</dd></div></dl><p className="orbit-drawer-note">{selected.detail}</p><button className="orbit-primary-button" onClick={() => setDetailOpen(false)}>Open full analysis <ArrowUpRight /></button></aside></div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const orbitStyles = `
|
||||
.orbit-page { --ink:#e9e8e5; --muted:#98999d; --faint:#6c6e73; --line:rgba(255,255,255,.09); --panel:rgba(19,20,25,.76); --ember:#e05c45; --ember-light:#ff8b70; min-height:100vh; overflow:hidden; position:relative; color:var(--ink); background:#101115; font-family:'Instrument Sans',system-ui,sans-serif; }
|
||||
.orbit-page *{box-sizing:border-box}.orbit-page button,.orbit-page a{font:inherit}.orbit-page button{cursor:pointer}.orbit-stars{position:fixed;inset:0;pointer-events:none;opacity:.75;background-image:radial-gradient(circle at 8% 14%,rgba(255,255,255,.6) 0 1px,transparent 1.5px),radial-gradient(circle at 18% 78%,rgba(255,255,255,.42) 0 1px,transparent 1.5px),radial-gradient(circle at 76% 13%,rgba(255,255,255,.46) 0 1px,transparent 1.5px),radial-gradient(circle at 95% 43%,rgba(255,255,255,.32) 0 1px,transparent 1.5px),radial-gradient(ellipse 35% 30% at 72% 30%,rgba(184,53,37,.15),transparent 70%),radial-gradient(ellipse 35% 38% at 14% 102%,rgba(203,80,53,.11),transparent 74%);}
|
||||
.orbit-nav{position:fixed;z-index:2;inset:0 auto 0 0;width:224px;display:flex;flex-direction:column;padding:30px 16px 22px;border-right:1px solid var(--line);background:rgba(14,15,19,.72);backdrop-filter:blur(20px)}.orbit-mark{display:flex;align-items:center;gap:9px;margin:0 12px 49px;color:var(--ink);font-family:'Bricolage Grotesque',sans-serif;font-size:22px;font-weight:600;letter-spacing:-.07em;text-decoration:none}.orbit-mark span{width:15px;height:15px;border-radius:50%;background:radial-gradient(circle at 34% 30%,#ffb090 0 8%,#ec765b 30%,#9d3227 74%);box-shadow:0 0 18px rgba(233,91,66,.5)}.orbit-nav nav{display:grid;gap:3px}.orbit-nav nav a{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:8px;color:#898b91;font-size:13px;text-decoration:none;transition:.2s}.orbit-nav nav a:hover{color:#ddd}.orbit-nav nav a.is-current{background:rgba(255,255,255,.065);color:#f3f1ed}.orbit-nav nav i{width:18px;color:#595b61;font-style:normal;font-size:9px;font-family:'IBM Plex Mono',monospace}.orbit-nav nav b{margin-left:auto;display:grid;place-items:center;width:19px;height:19px;border-radius:50%;background:rgba(224,92,69,.15);color:#f1846e;font:500 10px 'IBM Plex Mono',monospace}.orbit-nav-foot{display:flex;gap:9px;align-items:center;margin-top:auto;padding:12px}.orbit-avatar,.orbit-profile{display:grid;place-items:center;width:28px;height:28px;border:1px solid rgba(255,255,255,.14);border-radius:50%;background:#292a30;color:#ddd;font:500 10px 'IBM Plex Mono',monospace}.orbit-nav-foot strong,.orbit-nav-foot small{display:block}.orbit-nav-foot strong{font-size:11px;font-weight:500}.orbit-nav-foot small{margin-top:2px;color:#62646a;font-size:10px}
|
||||
.orbit-content{position:relative;z-index:1;max-width:1430px;margin-left:224px;padding:0 clamp(28px,5vw,76px) 80px}.orbit-topbar{height:80px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.orbit-breadcrumb{display:flex;align-items:center;gap:8px;color:#8f9195;font:500 11px 'IBM Plex Mono',monospace}.orbit-breadcrumb em{color:#484a50;font-style:normal}.orbit-live-dot{width:6px;height:6px;border-radius:50%;background:#ef7c62;box-shadow:0 0 10px #ef7c62}.orbit-top-actions{display:flex;align-items:center;gap:10px}.orbit-icon-button{height:29px;padding:0 9px;border:1px solid var(--line);border-radius:5px;background:transparent;color:#929399;font:10px 'IBM Plex Mono',monospace}.orbit-profile{border-color:rgba(224,92,69,.3);background:rgba(224,92,69,.1);color:#f18a74}
|
||||
.orbit-intro{display:flex;align-items:end;justify-content:space-between;padding:52px 0 33px}.orbit-intro p{margin:0 0 7px;color:#8e9096;font-size:14px}.orbit-intro h1{margin:0;color:#f1f0ed;font-family:'Bricolage Grotesque',sans-serif;font-size:clamp(32px,4vw,48px);font-weight:500;letter-spacing:-.055em;line-height:1}.orbit-quiet-button,.orbit-section-title>button{display:flex;align-items:center;gap:7px;border:0;background:none;color:#c6c5c5;font-size:12px}.orbit-quiet-button:hover,.orbit-section-title>button:hover{color:#fff}.orbit-quiet-button svg,.orbit-section-title svg,.orbit-metrics svg,.orbit-opportunity svg,.orbit-round-button svg,.orbit-drawer-close svg,.orbit-primary-button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
||||
.orbit-status{position:relative;display:grid;grid-template-columns:52px 1fr auto 34px;align-items:center;gap:17px;min-height:113px;padding:19px 20px;border:1px solid rgba(224,92,69,.22);border-radius:12px;background:linear-gradient(100deg,rgba(116,38,29,.34),rgba(27,24,27,.72) 36%,rgba(20,21,25,.84));box-shadow:inset 0 1px rgba(255,255,255,.025),0 18px 45px rgba(0,0,0,.15);overflow:hidden}.orbit-status:after{content:'';position:absolute;width:230px;height:230px;right:-90px;top:-144px;border:1px solid rgba(255,139,112,.11);border-radius:50%;box-shadow:0 0 0 32px rgba(255,139,112,.025),0 0 0 64px rgba(255,139,112,.017)}.orbit-status-orb{position:relative;width:52px;height:52px;border-radius:50%;background:radial-gradient(circle at 34% 29%,#ffc1a8 0 5%,#ef815f 19%,#c04332 54%,#62201e 100%);box-shadow:inset -9px -7px 14px rgba(55,8,7,.6),0 0 30px rgba(227,80,56,.22)}.orbit-status-orb span{position:absolute;inset:-7px;border:1px solid rgba(255,162,136,.2);border-radius:50%;transform:rotate(-23deg) scaleX(1.58)}.orbit-status-copy{position:relative;z-index:1}.orbit-status-copy p,.orbit-overline{margin:0;color:#df826f;font:500 10px 'IBM Plex Mono',monospace;letter-spacing:.11em;text-transform:uppercase}.orbit-status-copy h2{margin:5px 0 4px;font-size:18px;font-weight:500;letter-spacing:-.025em}.orbit-status-copy>span{color:#929398;font-size:12px}.orbit-regime{position:relative;z-index:1;display:grid;gap:3px;min-width:85px;margin-right:9px}.orbit-regime span{color:#83858a;font-size:10px}.orbit-regime strong{font-size:13px;font-weight:500}.orbit-regime small{color:#e38470;font:10px 'IBM Plex Mono',monospace}.orbit-round-button{position:relative;z-index:1;display:grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid rgba(255,255,255,.13);border-radius:50%;background:rgba(255,255,255,.04);color:#eee}.orbit-round-button:hover{background:rgba(255,255,255,.1)}
|
||||
.orbit-layout{display:grid;grid-template-columns:minmax(0,1.54fr) minmax(290px,.74fr);gap:18px;margin-top:18px}.orbit-primary-card,.orbit-positions,.orbit-watch,.orbit-opportunity{border:1px solid var(--line);border-radius:12px;background:var(--panel);box-shadow:inset 0 1px rgba(255,255,255,.025)}.orbit-primary-card{overflow:hidden}.orbit-card-head{display:flex;align-items:start;justify-content:space-between;padding:24px 27px 8px}.orbit-card-head h2{margin:7px 0 0;font:500 30px 'Bricolage Grotesque',sans-serif;letter-spacing:-.06em}.orbit-card-head h2 span,.orbit-drawer h2 span{margin-left:10px;color:#8a8c91;font:400 12px 'Instrument Sans',sans-serif;letter-spacing:0}.orbit-confidence{display:grid;place-items:center;width:57px;height:57px;padding:0;border:1px solid rgba(224,92,69,.4);border-radius:50%;background:rgba(224,92,69,.08);color:#ff987d}.orbit-confidence b{font:500 17px 'IBM Plex Mono',monospace;line-height:1}.orbit-confidence span{margin-top:2px;font-size:8px}.orbit-price-row{display:flex;align-items:end;gap:38px;padding:4px 27px 11px}.orbit-price-row span,.orbit-metrics span{display:block;color:#85878c;font-size:10px}.orbit-price-row strong{display:inline-block;margin-top:5px;font:500 22px 'IBM Plex Mono',monospace;letter-spacing:-.05em}.orbit-price-row small{margin-left:8px;color:#ec7e67;font:11px 'IBM Plex Mono',monospace}.orbit-call{margin-bottom:1px;padding-left:15px;border-left:1px solid rgba(255,255,255,.16)}.orbit-call b{display:block;margin-top:4px;font-size:12px;font-weight:500}.orbit-chart-wrap{position:relative;height:205px;margin:0 20px}.orbit-price-path{display:block;width:100%;height:100%}.orbit-chart-grid{fill:none;stroke:rgba(255,255,255,.065);stroke-width:1}.orbit-chart-fill{fill:url(#orbit-fill)}.orbit-chart-line{fill:none;stroke:#ef745a;stroke-width:1.5}.orbit-chart-dot{fill:#ff9d84}.orbit-chart-label{position:absolute;bottom:8px;color:#6d6e73;font:9px 'IBM Plex Mono',monospace}.orbit-chart-label.left{left:7px}.orbit-chart-label.mid{left:49%}.orbit-chart-label.right{right:7px}.orbit-target{position:absolute;right:17px;top:21px;display:grid;gap:2px;padding:7px 9px;border:1px solid rgba(255,145,118,.2);border-radius:6px;background:rgba(47,25,26,.82)}.orbit-target span{color:#b7796d;font-size:9px}.orbit-target b{font:500 11px 'IBM Plex Mono',monospace}.orbit-metrics{display:grid;grid-template-columns:repeat(3,1fr) auto;gap:10px;align-items:center;padding:16px 27px 19px;border-top:1px solid var(--line)}.orbit-metrics strong{display:block;margin-top:4px;font:500 15px 'IBM Plex Mono',monospace}.orbit-metrics strong span{display:inline;color:#a6a7aa;font-size:10px}.orbit-metrics button{display:flex;align-items:center;gap:4px;border:0;background:none;color:#dedddb;font-size:11px;white-space:nowrap}.orbit-metrics button:hover{color:#ff9c84}
|
||||
.orbit-side-stack{display:grid;grid-template-rows:1fr .9fr;gap:18px}.orbit-positions,.orbit-watch{padding:22px}.orbit-section-title{display:flex;align-items:start;justify-content:space-between}.orbit-section-title h2{margin:6px 0 0;font-size:17px;font-weight:500;letter-spacing:-.035em}.orbit-section-title>div>span{display:block;margin-top:6px;color:#898b91;font-size:11px}.orbit-section-title>button{padding:3px 0;font-size:10px}.orbit-position-meter{display:flex;align-items:center;gap:15px;margin-top:22px}.orbit-meter-track{width:58px;height:58px;padding:5px;border-radius:50%;background:conic-gradient(#e25d46 0 38%,#2b2d32 38% 100%)}.orbit-meter-track:before{content:'';display:block;width:100%;height:100%;border-radius:50%;background:#1a1b20}.orbit-position-meter>div:last-child{display:grid;gap:4px}.orbit-position-meter b{font:500 20px 'IBM Plex Mono',monospace}.orbit-position-meter span{color:#8b8d92;font-size:10px}.orbit-position-values{display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:20px;padding-top:15px;border-top:1px solid var(--line)}.orbit-position-values span{display:block;color:#85878a;font-size:10px}.orbit-position-values b{display:block;margin-top:4px;font:500 12px 'IBM Plex Mono',monospace}.positive{color:#ec816a}.orbit-watch{position:relative;overflow:hidden;background:linear-gradient(135deg,rgba(46,27,29,.8),rgba(21,22,27,.9) 70%)}.orbit-watch:before{content:'';position:absolute;width:160px;height:160px;right:-79px;bottom:-99px;border:1px solid rgba(239,116,89,.13);border-radius:50%;box-shadow:0 0 0 28px rgba(239,116,89,.025)}.orbit-watch-names{position:relative;z-index:1;display:flex;gap:7px;margin-top:19px}.orbit-watch-names button{display:grid;gap:4px;min-width:0;padding:7px 9px;border:1px solid rgba(255,255,255,.08);border-radius:6px;background:rgba(255,255,255,.035);color:#eceae7;text-align:left}.orbit-watch-names button:hover{border-color:rgba(255,139,112,.35)}.orbit-watch-names b{font:500 11px 'IBM Plex Mono',monospace}.orbit-watch-names span{color:#e6816c;font:9px 'IBM Plex Mono',monospace}
|
||||
.orbit-opportunities{margin-top:52px}.orbit-opportunities>.orbit-section-title{align-items:end;margin-bottom:15px}.orbit-opportunities>.orbit-section-title h2{font-size:24px}.orbit-opportunity-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.orbit-opportunity{position:relative;display:grid;grid-template-columns:20px 1fr auto 15px;align-items:center;gap:12px;width:100%;padding:19px 17px;color:#e8e6e3;text-align:left;transition:border-color .2s,transform .2s,background .2s}.orbit-opportunity:hover{transform:translateY(-2px);border-color:rgba(224,92,69,.35);background:#202126}.orbit-rank{align-self:start;color:#65666c;font:9px 'IBM Plex Mono',monospace}.orbit-opportunity-name{display:grid;gap:4px}.orbit-opportunity-name strong{font:500 18px 'Bricolage Grotesque',sans-serif;letter-spacing:-.045em}.orbit-opportunity-name span{color:#83858b;font-size:10px}.orbit-signal{display:flex;gap:5px;align-items:center;color:#a6a7aa;font-size:10px}.orbit-signal i{width:5px;height:5px;border-radius:50%;background:#e97660}.orbit-signal i.short{background:#8e91a0}.orbit-opportunity-score{display:grid;justify-items:end;gap:3px}.orbit-opportunity-score b{font:500 16px 'IBM Plex Mono',monospace}.orbit-opportunity-score span{color:#7e8086;font-size:9px}.orbit-opportunity>svg{color:#898b8e}
|
||||
.orbit-drawer-backdrop{position:fixed;z-index:10;inset:0;display:flex;justify-content:flex-end;background:rgba(3,4,6,.62);backdrop-filter:blur(3px);animation:orbit-fade .2s ease}.orbit-drawer{position:relative;width:min(440px,100%);height:100%;padding:70px 37px 32px;border-left:1px solid rgba(255,255,255,.1);background:#17181d;box-shadow:-25px 0 60px rgba(0,0,0,.35);animation:orbit-drawer-in .26s ease}.orbit-drawer-close{position:absolute;top:22px;right:24px;display:grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid var(--line);border-radius:50%;background:transparent;color:#d7d6d3}.orbit-drawer h2{margin:10px 0 0;font:500 38px 'Bricolage Grotesque',sans-serif;letter-spacing:-.06em}.orbit-drawer-price{margin:24px 0 28px;font:500 25px 'IBM Plex Mono',monospace}.orbit-drawer-price b{margin-left:8px;color:#ed8068;font-size:12px;font-weight:400}.orbit-drawer-action{padding:15px;border:1px solid rgba(224,92,69,.25);border-radius:9px;background:rgba(224,92,69,.07)}.orbit-drawer-action span{display:block;color:#d77b68;font-size:10px}.orbit-drawer-action strong{display:block;margin-top:5px;font-size:14px;font-weight:500;line-height:1.4}.orbit-drawer dl{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:28px 0;padding:17px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.orbit-drawer dt{color:#888a8f;font-size:10px}.orbit-drawer dd{margin:5px 0 0;font:500 12px 'IBM Plex Mono',monospace}.orbit-drawer-note{color:#a5a6ab;font-size:13px;line-height:1.6}.orbit-primary-button{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:30px;padding:13px;border:0;border-radius:7px;background:#e36a50;color:#241513;font-weight:600;font-size:12px}.orbit-primary-button:hover{background:#f07b61}.orbit-primary-button svg{width:14px}@keyframes orbit-fade{from{opacity:0}to{opacity:1}}@keyframes orbit-drawer-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
||||
@media(max-width:1000px){.orbit-nav{width:185px}.orbit-content{margin-left:185px;padding-left:28px;padding-right:28px}.orbit-layout{grid-template-columns:1fr}.orbit-side-stack{grid-template-columns:1fr 1fr;grid-template-rows:none}.orbit-opportunity-grid{grid-template-columns:1fr}.orbit-opportunity{grid-template-columns:20px 1fr auto auto 15px}}
|
||||
@media(max-width:700px){.orbit-nav{position:relative;width:100%;height:auto;min-height:65px;padding:14px 18px;flex-direction:row;align-items:center;border-right:0;border-bottom:1px solid var(--line)}.orbit-mark{margin:0;font-size:20px}.orbit-nav nav{display:none}.orbit-nav-foot{margin:0 0 0 auto;padding:0}.orbit-nav-foot div{display:none}.orbit-content{margin-left:0;padding:0 18px 48px}.orbit-topbar{height:58px}.orbit-breadcrumb{font-size:9px}.orbit-top-actions .orbit-icon-button{display:none}.orbit-intro{align-items:start;padding:36px 0 25px}.orbit-intro h1{font-size:35px}.orbit-quiet-button{display:none}.orbit-status{grid-template-columns:39px 1fr 30px;gap:12px;padding:16px}.orbit-status-orb{width:39px;height:39px}.orbit-regime{display:none}.orbit-status-copy h2{font-size:14px}.orbit-status-copy>span{font-size:10px;line-height:1.35;display:block}.orbit-card-head,.orbit-price-row{padding-left:18px;padding-right:18px}.orbit-card-head{padding-top:19px}.orbit-chart-wrap{height:160px;margin:0 11px}.orbit-price-row{gap:16px;align-items:start;flex-direction:column}.orbit-call{padding-left:10px}.orbit-metrics{grid-template-columns:repeat(3,1fr);padding:14px 18px}.orbit-metrics button{grid-column:1 / -1;margin-top:5px}.orbit-side-stack{grid-template-columns:1fr;grid-template-rows:auto}.orbit-opportunities{margin-top:38px}.orbit-opportunities>.orbit-section-title{align-items:end}.orbit-opportunities>.orbit-section-title>button{display:none}.orbit-opportunity{grid-template-columns:19px 1fr auto 13px;padding:15px}.orbit-signal{display:none}.orbit-drawer{padding:65px 25px 28px}.orbit-status-copy p{font-size:9px}}
|
||||
@media(prefers-reduced-motion:reduce){.orbit-page *{animation:none!important;transition:none!important}}
|
||||
`;
|
||||
+178
-33
@@ -3,13 +3,30 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Horizon theme tokens — chart colors validated on the dark surface */
|
||||
--void: #0a0b11;
|
||||
--surface: #11131c;
|
||||
--panel-glass: rgba(15, 17, 26, 0.72);
|
||||
--panel-line: rgba(226, 231, 245, 0.085);
|
||||
--grid: rgba(226, 231, 245, 0.07);
|
||||
--ink: #edeef3;
|
||||
--ink-2: #9aa0b0;
|
||||
--ink-3: #5d6373;
|
||||
--up: #2f9db2;
|
||||
--up-text: #6ec9db;
|
||||
--down: #e36a58;
|
||||
--down-text: #ef9182;
|
||||
--ember: #ff6a45; /* reserved: horizon, wordmark, top-pick */
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0e120f;
|
||||
background: var(--void);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Number inputs: drop the native spinner arrows app-wide — they don't fit the
|
||||
@@ -25,7 +42,7 @@
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
/* Atmosphere: faint graph-paper grid + citron signal glow + film grain */
|
||||
/* Atmosphere: faint starfield + soft rim-cyan / ember glows + film grain */
|
||||
#root {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
@@ -36,12 +53,21 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background:
|
||||
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;
|
||||
background-image:
|
||||
radial-gradient(ellipse 60% 40% at 85% 0%, rgba(47, 157, 178, 0.05) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 70% 30% at 50% 100%, rgba(228, 87, 61, 0.04) 0%, transparent 60%),
|
||||
radial-gradient(1px 1px at 12% 18%, rgba(237, 238, 243, 0.5) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 68% 8%, rgba(237, 238, 243, 0.35) 50%, transparent 51%),
|
||||
radial-gradient(1.5px 1.5px at 84% 32%, rgba(159, 216, 232, 0.45) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 33% 44%, rgba(237, 238, 243, 0.3) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 91% 64%, rgba(237, 238, 243, 0.28) 50%, transparent 51%),
|
||||
radial-gradient(1.5px 1.5px at 22% 71%, rgba(237, 238, 243, 0.4) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 55% 27%, rgba(255, 150, 120, 0.3) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 45% 87%, rgba(237, 238, 243, 0.28) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 76% 82%, rgba(159, 216, 232, 0.3) 50%, transparent 51%),
|
||||
radial-gradient(1px 1px at 5% 55%, rgba(237, 238, 243, 0.3) 50%, transparent 51%);
|
||||
background-size: 100% 100%, 100% 100%, 900px 700px, 900px 700px, 900px 700px, 900px 700px, 900px 700px, 900px 700px, 900px 700px, 900px 700px, 900px 700px, 900px 700px;
|
||||
background-repeat: no-repeat, no-repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -58,20 +84,57 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Panel — the core building block (kept class names so all components reskin) */
|
||||
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
|
||||
.app-horizon {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 22vh;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-horizon-planet {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 58%;
|
||||
width: 300vw;
|
||||
height: 300vw;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(ellipse 55% 14% at 50% 0.4%, rgba(255, 120, 80, 0.22), transparent 60%),
|
||||
radial-gradient(ellipse 120% 26% at 50% 0%, #3f130b 0%, #260a06 32%, #130504 58%, #0a0b11 86%);
|
||||
}
|
||||
.app-horizon-rim {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(58% - 2px);
|
||||
width: 300vw;
|
||||
height: 300vw;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, rgba(213, 240, 250, 0.55), rgba(159, 216, 232, 0.16) 40%, transparent 60%);
|
||||
filter: blur(1px);
|
||||
box-shadow: 0 -14px 50px rgba(159, 216, 232, 0.1), 0 -50px 140px rgba(228, 87, 61, 0.08);
|
||||
}
|
||||
|
||||
/* Panel — the core building block (kept class names so all components reskin).
|
||||
Denser than plain glass so the horizon glows between panels, not through them. */
|
||||
.glass {
|
||||
background: rgba(238, 240, 233, 0.035);
|
||||
background: var(--panel-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(223, 242, 178, 0.09);
|
||||
border: 1px solid var(--panel-line);
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
|
||||
.glass-sm {
|
||||
background: rgba(238, 240, 233, 0.028);
|
||||
background: rgba(15, 17, 26, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(223, 242, 178, 0.07);
|
||||
border: 1px solid rgba(226, 231, 245, 0.07);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -79,49 +142,49 @@
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.glass-hover:hover {
|
||||
background: rgba(238, 240, 233, 0.06);
|
||||
border-color: rgba(214, 242, 92, 0.22);
|
||||
background: rgba(21, 24, 35, 0.8);
|
||||
border-color: rgba(110, 201, 219, 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(120deg, #d6f25c, #6ee7b7);
|
||||
background: linear-gradient(120deg, #9fd8e8, #6ec9db);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Primary button — luminous citron with ink text. The signature element. */
|
||||
/* Primary button — luminous rim-cyan with ink text. */
|
||||
.btn-primary {
|
||||
background: #c3e82e;
|
||||
color: #16190a;
|
||||
border: 1px solid rgba(214, 242, 92, 0.5);
|
||||
background: #2f9db2;
|
||||
color: #06181c;
|
||||
border: 1px solid rgba(110, 201, 219, 0.5);
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #d6f25c;
|
||||
box-shadow: 0 0 20px rgba(196, 232, 46, 0.35);
|
||||
background: #4ab3c8;
|
||||
box-shadow: 0 0 20px rgba(79, 182, 203, 0.35);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input-glass {
|
||||
background: rgba(238, 240, 233, 0.04);
|
||||
border: 1px solid rgba(223, 242, 178, 0.12);
|
||||
background: rgba(226, 231, 245, 0.04);
|
||||
border: 1px solid rgba(226, 231, 245, 0.12);
|
||||
border-radius: 0.5rem;
|
||||
color: #eef0e9;
|
||||
color: #edeef3;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.input-glass::placeholder {
|
||||
color: rgba(149, 157, 135, 0.55);
|
||||
color: rgba(154, 160, 176, 0.55);
|
||||
}
|
||||
.input-glass:focus {
|
||||
outline: none;
|
||||
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);
|
||||
border-color: rgba(110, 201, 219, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(47, 157, 178, 0.14);
|
||||
background: rgba(226, 231, 245, 0.06);
|
||||
}
|
||||
|
||||
/* Numbers — every metric reads in tabular mono */
|
||||
@@ -135,10 +198,92 @@
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: #6e7562;
|
||||
color: #5d6373;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* --- Horizon chart primitives (components/charts/horizon.tsx) --- */
|
||||
|
||||
/* Price rail: stop → entry → now → target laid out spatially */
|
||||
.hz-rail { position: relative; height: 108px; margin: 34px 8px 4px; }
|
||||
.hz-rail-track {
|
||||
position: absolute; left: 0; right: 0; top: 50%; height: 1px;
|
||||
background: var(--grid);
|
||||
}
|
||||
.hz-rail-risk {
|
||||
position: absolute; top: calc(50% - 2px); height: 4px;
|
||||
background: rgba(227, 106, 88, 0.16); border-radius: 2px;
|
||||
}
|
||||
.hz-rail-progress {
|
||||
position: absolute; top: calc(50% - 2px); height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.hz-rail-mark { position: absolute; top: 0; bottom: 0; width: 0; }
|
||||
.hz-rail-tick {
|
||||
position: absolute; top: calc(50% - 7px); left: -1px;
|
||||
width: 2px; height: 14px; border-radius: 1px;
|
||||
}
|
||||
.hz-rail-dot {
|
||||
position: absolute; top: calc(50% - 6px); left: -6px;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--surface);
|
||||
box-shadow: 0 0 0 1px var(--up), 0 0 14px rgba(110, 201, 219, 0.45);
|
||||
}
|
||||
.hz-rail-ring {
|
||||
position: absolute; top: calc(50% - 6px); left: -6px;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--up); background: var(--void);
|
||||
}
|
||||
.hz-rail-label {
|
||||
position: absolute; top: calc(50% + 16px); left: 0; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hz-rail-label-top { top: auto; bottom: calc(50% + 16px); }
|
||||
.hz-rail-label em {
|
||||
font-style: normal; font-family: 'IBM Plex Mono', monospace; font-size: 9.5px;
|
||||
letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-3);
|
||||
}
|
||||
.hz-rail-label b {
|
||||
font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: 13px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.hz-rail-label i {
|
||||
font-style: normal; font-family: 'IBM Plex Mono', monospace; font-size: 10.5px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* Diverging R-multiple bar */
|
||||
.hz-rbar { position: relative; height: 10px; }
|
||||
.hz-rbar-zero {
|
||||
position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px;
|
||||
background: var(--panel-line);
|
||||
}
|
||||
.hz-rbar-fill { position: absolute; top: 0; bottom: 0; }
|
||||
|
||||
/* SVG level labels (trade chart) */
|
||||
.hz-tradechart { width: 100%; height: auto; display: block; }
|
||||
.hz-lvl { fill: var(--ink-3); font-family: 'IBM Plex Mono', monospace; font-size: 10px; }
|
||||
.hz-lvl-up { fill: var(--up-text); }
|
||||
.hz-lvl-down { fill: var(--down-text); }
|
||||
|
||||
/* Radar fingerprint */
|
||||
.hz-radar-wrap { position: relative; display: inline-block; }
|
||||
.hz-radar-axis {
|
||||
fill: var(--ink-3); font-family: 'IBM Plex Mono', monospace; font-size: 9.5px;
|
||||
letter-spacing: 0.04em; text-transform: capitalize;
|
||||
}
|
||||
.hz-radar-axisval { fill: var(--ink-2); font-weight: 600; }
|
||||
.hz-radar-tip {
|
||||
position: absolute; transform: translate(-50%, calc(-100% - 12px));
|
||||
background: rgba(21, 24, 35, 0.96); border: 1px solid var(--panel-line);
|
||||
border-radius: 8px; padding: 6px 10px; white-space: nowrap;
|
||||
pointer-events: none; z-index: 5; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
.hz-radar-tip b { display: block; color: var(--ink); font-weight: 600; text-transform: capitalize; }
|
||||
.hz-radar-tip span { display: block; color: var(--up-text); font-size: 11px; margin-top: 1px; }
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -148,10 +293,10 @@
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(223, 242, 178, 0.12);
|
||||
background: rgba(226, 231, 245, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(223, 242, 178, 0.22);
|
||||
background: rgba(226, 231, 245, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
+55
-26
@@ -7,45 +7,74 @@ export default {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Instrument Sans"', 'system-ui', 'sans-serif'],
|
||||
display: ['"Bricolage Grotesque"', 'system-ui', 'sans-serif'],
|
||||
display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
// Warm ash neutrals (green-tinted) — replaces Tailwind's cool gray
|
||||
// Horizon theme: cool space neutrals — replaces Tailwind's gray
|
||||
// throughout the app, since components reference gray-* directly.
|
||||
gray: {
|
||||
50: '#f7f8f4',
|
||||
100: '#eef0e9',
|
||||
200: '#dde1d4',
|
||||
300: '#bfc5b2',
|
||||
400: '#959d87',
|
||||
500: '#6e7562',
|
||||
600: '#4d5344',
|
||||
700: '#373c30',
|
||||
800: '#22261d',
|
||||
900: '#151911',
|
||||
950: '#0e120f',
|
||||
50: '#f4f5f8',
|
||||
100: '#edeef3',
|
||||
200: '#dde0e8',
|
||||
300: '#bcc1cd',
|
||||
400: '#9aa0b0',
|
||||
500: '#6e7484',
|
||||
600: '#4d5363',
|
||||
700: '#373c49',
|
||||
800: '#232733',
|
||||
900: '#14161f',
|
||||
950: '#0a0b11',
|
||||
},
|
||||
// Citron signal accent — replaces blue so every accent reference
|
||||
// Atmosphere-rim cyan accent — every accent reference
|
||||
// (text-blue-*, bg-blue-*, ring-blue-*) lights up in the brand color.
|
||||
// Validated against the dark surface (dataviz six checks).
|
||||
blue: {
|
||||
50: '#fafee8',
|
||||
100: '#f2fcc6',
|
||||
200: '#e6f996',
|
||||
300: '#d6f25c',
|
||||
400: '#c3e82e',
|
||||
500: '#a4cd14',
|
||||
600: '#82a40b',
|
||||
700: '#627c0e',
|
||||
800: '#4e6212',
|
||||
900: '#425314',
|
||||
950: '#222e05',
|
||||
50: '#ecfbfe',
|
||||
100: '#d4f4fa',
|
||||
200: '#ade8f2',
|
||||
300: '#6ec9db',
|
||||
400: '#4ab3c8',
|
||||
500: '#2f9db2',
|
||||
600: '#23808f',
|
||||
700: '#1d6675',
|
||||
800: '#1a525e',
|
||||
900: '#17434e',
|
||||
950: '#0a2b33',
|
||||
},
|
||||
// Positive/up shares the cyan family (mockup: one "up" color, no green).
|
||||
emerald: {
|
||||
50: '#ecfbfe',
|
||||
100: '#d4f4fa',
|
||||
200: '#ade8f2',
|
||||
300: '#6ec9db',
|
||||
400: '#4ab3c8',
|
||||
500: '#2f9db2',
|
||||
600: '#23808f',
|
||||
700: '#1d6675',
|
||||
800: '#1a525e',
|
||||
900: '#17434e',
|
||||
950: '#0a2b33',
|
||||
},
|
||||
// Negative/down: muted rose (Mars ember stays reserved for atmosphere).
|
||||
red: {
|
||||
50: '#fdf1ef',
|
||||
100: '#fbe0dc',
|
||||
200: '#f6c2ba',
|
||||
300: '#f0a094',
|
||||
400: '#ea8271',
|
||||
500: '#e36a58',
|
||||
600: '#c95441',
|
||||
700: '#a84334',
|
||||
800: '#86382c',
|
||||
900: '#6e3128',
|
||||
950: '#3b1712',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: 'rgba(255, 255, 255, 0.04)',
|
||||
hover: 'rgba(255, 255, 255, 0.07)',
|
||||
active: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'rgba(223, 242, 178, 0.09)',
|
||||
border: 'rgba(226, 231, 245, 0.09)',
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/tickersearch.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/charts/horizon.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/tickersearch.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/designhorizonpage.tsx","./src/pages/designmockupspage.tsx","./src/pages/designorbitpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user