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>
1392 lines
60 KiB
TypeScript
1392 lines
60 KiB
TypeScript
/**
|
||
* Design study "Horizon" — v2 mockup.
|
||
*
|
||
* Fully self-contained: static mock data, no hooks into the API, no auth.
|
||
* View at /design-horizon with `npm run dev` — no backend or DB needed.
|
||
*
|
||
* Direction: space void + dim Mars horizon (glow stays atmosphere, never data),
|
||
* one focal "what should I do today" card, zero classic tables, drill-down
|
||
* everywhere. Chart colors validated (CVD + contrast) against the dark surface.
|
||
*/
|
||
import { useMemo, useRef, useState } from 'react';
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Mock data */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const heroSetup = {
|
||
symbol: 'VRT',
|
||
name: 'Vertiv Holdings',
|
||
direction: 'long' as const,
|
||
entry: 92.1,
|
||
stop: 86.4,
|
||
current: 94.3,
|
||
target: 107.0,
|
||
rr: 2.6,
|
||
targetProb: 58,
|
||
residualPct: 94,
|
||
conviction: 'High',
|
||
sizeHint: 'vol-adjusted size 6.2% of book',
|
||
};
|
||
|
||
const positions = [
|
||
{
|
||
symbol: 'ANET', name: 'Arista Networks', direction: 'long', r: 1.4,
|
||
entry: 118.4, current: 131.2, shares: 25, opened: 'May 28',
|
||
stop: 121.8, stopNote: 'trailed from 111.10', target: 140.0,
|
||
alphaUsd: 310, exit: 'ATR trailing stop',
|
||
spark: [118.4, 119.2, 121.5, 120.8, 123.4, 125.1, 124.2, 126.8, 128.3, 127.5, 129.9, 131.2],
|
||
stopTrail: [111.1, 111.1, 111.1, 112.6, 114.2, 114.2, 115.8, 117.4, 117.4, 119.5, 121.8, 121.8],
|
||
},
|
||
{
|
||
symbol: 'VRT', name: 'Vertiv Holdings', direction: 'long', r: 1.1,
|
||
entry: 88.3, current: 94.3, shares: 40, opened: 'Jun 12',
|
||
stop: 86.9, stopNote: 'trailed from 83.10', target: 103.0,
|
||
alphaUsd: 205, exit: 'ATR trailing stop',
|
||
spark: [88.3, 89.1, 88.6, 90.2, 91.4, 90.8, 92.1, 93.0, 92.4, 93.6, 94.1, 94.3],
|
||
stopTrail: [83.1, 83.1, 84.0, 84.0, 84.9, 84.9, 85.6, 85.6, 86.2, 86.2, 86.9, 86.9],
|
||
},
|
||
{
|
||
symbol: 'CLS', name: 'Celestica', direction: 'long', r: 0.4,
|
||
entry: 61.2, current: 62.9, shares: 60, opened: 'Jun 30',
|
||
stop: 57.1, stopNote: 'initial stop', target: 71.5,
|
||
alphaUsd: 84, exit: 'ATR trailing stop',
|
||
spark: [61.2, 61.8, 61.1, 62.3, 62.0, 62.6, 63.1, 62.7, 62.4, 62.9, 63.2, 62.9],
|
||
stopTrail: [57.1, 57.1, 57.1, 57.1, 57.1, 57.1, 57.1, 57.1, 57.1, 57.1, 57.1, 57.1],
|
||
},
|
||
{
|
||
symbol: 'SMCI', name: 'Super Micro Computer', direction: 'short', r: -0.6,
|
||
entry: 44.8, current: 46.1, shares: 80, opened: 'Jul 2',
|
||
stop: 47.0, stopNote: 'initial stop', target: 38.5,
|
||
alphaUsd: -57, exit: 'ATR trailing stop',
|
||
spark: [44.8, 44.2, 45.0, 45.6, 45.1, 45.9, 46.4, 45.8, 46.2, 46.0, 46.3, 46.1],
|
||
stopTrail: [47.0, 47.0, 47.0, 47.0, 47.0, 47.0, 47.0, 47.0, 47.0, 47.0, 47.0, 47.0],
|
||
},
|
||
];
|
||
|
||
const radar = [
|
||
{
|
||
rank: 2, symbol: 'CLS', name: 'Celestica', direction: 'long',
|
||
residual: 91, rr: '2.2:1', prob: 61, qualified: true, note: 'clears EV gate',
|
||
},
|
||
{
|
||
rank: 3, symbol: 'APP', name: 'AppLovin', direction: 'long',
|
||
residual: 89, rr: '2.4:1', prob: 55, qualified: true, note: 'clears EV gate',
|
||
},
|
||
{
|
||
rank: 4, symbol: 'NVDA', name: 'NVIDIA', direction: 'long',
|
||
residual: 87, rr: '1.6:1', prob: 63, qualified: false, note: 'EV below gate — payoff too thin',
|
||
},
|
||
{
|
||
rank: 5, symbol: 'AMD', name: 'Advanced Micro Devices', direction: 'long',
|
||
residual: 84, rr: '2.1:1', prob: 48, qualified: false, note: 'extended — 12% above entry zone',
|
||
},
|
||
{
|
||
rank: 6, symbol: 'PLTR', name: 'Palantir', direction: 'short',
|
||
residual: 82, rr: '1.9:1', prob: 44, qualified: false, note: 'counter-trend in neutral regime',
|
||
},
|
||
];
|
||
|
||
const watchlist = [
|
||
{ symbol: 'VRT', pct: 3.1 }, { symbol: 'ANET', pct: 2.4 },
|
||
{ symbol: 'NVDA', pct: 1.8 }, { symbol: 'AVGO', pct: 1.5 },
|
||
{ symbol: 'CRWD', pct: 0.9 }, { symbol: 'MSFT', pct: 0.3 },
|
||
{ symbol: 'PLTR', pct: -0.9 }, { symbol: 'META', pct: -1.2 },
|
||
];
|
||
|
||
/* Weekly closes since January, indexed to 100 — one axis, common base. */
|
||
const perfPortfolio = [100, 101.2, 100.6, 102.3, 103.8, 103.1, 105.0, 106.4, 105.2, 107.9, 109.6, 108.8, 111.3, 110.1, 112.6, 114.9, 113.8, 116.2, 118.4, 117.1, 119.8, 121.6, 120.4, 123.1, 125.0, 126.4];
|
||
const perfSpy = [100, 100.8, 100.2, 101.5, 102.1, 101.4, 102.6, 103.3, 102.5, 103.9, 104.6, 103.8, 104.9, 104.1, 105.2, 106.0, 105.3, 106.4, 107.2, 106.5, 107.4, 108.1, 107.3, 108.0, 108.6, 108.2];
|
||
const perfStart = new Date(2026, 0, 9);
|
||
const perfDates = perfPortfolio.map((_, i) => {
|
||
const d = new Date(perfStart);
|
||
d.setDate(d.getDate() + i * 7);
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||
});
|
||
|
||
const alphaSpark = [0, 42, 30, 95, 150, 128, 210, 265, 231, 340, 415, 388, 470, 442, 508, 570, 538, 590, 642];
|
||
|
||
/* ~3 months of daily VRT candles, seeded so the mock is stable across reloads. */
|
||
type Candle = { d: Date; o: number; h: number; l: number; c: number; v: number };
|
||
const vrtCandles: Candle[] = (() => {
|
||
let s = 42 >>> 0;
|
||
const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
||
const days: Date[] = [];
|
||
const cursor = new Date(2026, 6, 9);
|
||
while (days.length < 64) {
|
||
const dow = cursor.getDay();
|
||
if (dow !== 0 && dow !== 6) days.unshift(new Date(cursor));
|
||
cursor.setDate(cursor.getDate() - 1);
|
||
}
|
||
const drift = (i: number) => (i < 20 ? 0.30 : i < 28 ? -0.38 : i < 46 ? 0.44 : i < 53 ? -0.32 : 0.52);
|
||
const out: Candle[] = [];
|
||
let close = 73.6;
|
||
for (let i = 0; i < 64; i++) {
|
||
const chg = drift(i) + (rnd() - 0.5) * 2.4;
|
||
const o = close;
|
||
const c = o + chg;
|
||
out.push({
|
||
d: days[i], o, c,
|
||
h: Math.max(o, c) + rnd() * 1.2,
|
||
l: Math.min(o, c) - rnd() * 1.2,
|
||
v: 1.4e6 * (0.55 + rnd()) * (1 + Math.abs(chg) / 2.5),
|
||
});
|
||
close = c;
|
||
}
|
||
const shift = 94.3 - close;
|
||
return out.map((b) => ({ ...b, o: b.o + shift, h: b.h + shift, l: b.l + shift, c: b.c + shift }));
|
||
})();
|
||
|
||
const tickerLevels = {
|
||
resistance: 96.4,
|
||
supportLo: 88.7,
|
||
supportHi: 90.1,
|
||
};
|
||
|
||
/* Score fingerprint — all axes 0–100 percentile, same order everywhere. */
|
||
const RADAR_AXES = ['Momentum', 'Trend', 'Setup', 'Sentiment', 'Fundam.'];
|
||
const RADAR_AXES_FULL = [
|
||
'Residual momentum', 'Trend strength', 'Setup quality', 'News sentiment', 'Fundamentals',
|
||
];
|
||
const heroScores = [94, 88, 85, 71, 62];
|
||
const liveMedian = [68, 61, 57, 52, 55];
|
||
const qualifiedCompare = [
|
||
{ symbol: 'VRT', tag: 'top pick', scores: heroScores },
|
||
{ symbol: 'CLS', tag: 'rank 2', scores: [91, 82, 72, 58, 74] },
|
||
{ symbol: 'APP', tag: 'rank 3', scores: [89, 78, 69, 66, 81] },
|
||
];
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Small building blocks */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function fmt(n: number, digits = 2): string {
|
||
return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||
}
|
||
|
||
function signed(n: number, unit = ''): string {
|
||
return `${n > 0 ? '+' : n < 0 ? '−' : ''}${fmt(Math.abs(n))}${unit}`;
|
||
}
|
||
|
||
function DirTag({ direction }: { direction: string }) {
|
||
return (
|
||
<span className={`hzn-tag ${direction === 'long' ? 'hzn-tag-long' : 'hzn-tag-short'}`}>
|
||
{direction}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 2px sparkline, endpoint dot with surface ring. Single series — no legend. */
|
||
function Spark({ data, color, w = 130, h = 34 }: { data: number[]; color: string; w?: number; h?: number }) {
|
||
const min = Math.min(...data);
|
||
const max = Math.max(...data);
|
||
const span = max - min || 1;
|
||
const px = (i: number) => 3 + (i / (data.length - 1)) * (w - 10);
|
||
const py = (v: number) => h - 5 - ((v - min) / span) * (h - 10);
|
||
const path = data.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
|
||
return (
|
||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
|
||
<path d={path} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||
<circle cx={px(data.length - 1)} cy={py(data[data.length - 1])} r="4" fill={color} stroke="var(--surface)" strokeWidth="2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/** Diverging R-multiple bar: grows from a centered zero baseline. */
|
||
function RBar({ r, max = 1.6 }: { r: number; max?: number }) {
|
||
const pct = Math.min(Math.abs(r) / max, 1) * 50;
|
||
return (
|
||
<div className="hzn-rbar" role="img" aria-label={`${signed(r)} R`}>
|
||
<span className="hzn-rbar-zero" />
|
||
<span
|
||
className="hzn-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>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Radar — the score fingerprint (5 axes, 0–100 %ile, fixed order) */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function RadarChart({
|
||
scores, reference, size = 110, labels = false,
|
||
}: { scores: number[]; reference?: number[]; size?: number; labels?: boolean }) {
|
||
const [hover, setHover] = useState<number | null>(null);
|
||
const pad = labels ? 78 : 10;
|
||
const vb = size + pad * 2;
|
||
const c = vb / 2;
|
||
const r = size / 2;
|
||
const pt = (v: number, i: number): [number, number] => {
|
||
const a = ((-90 + i * 72) * Math.PI) / 180;
|
||
return [c + (Math.cos(a) * r * v) / 100, c + (Math.sin(a) * r * v) / 100];
|
||
};
|
||
const poly = (vals: number[]) =>
|
||
vals.map((v, i) => pt(v, i).map((n) => n.toFixed(1)).join(',')).join(' ');
|
||
const rendered = vb * (labels ? 0.95 : 1);
|
||
const tip = hover !== null ? pt(scores[hover], hover) : null;
|
||
return (
|
||
<div className="hzn-radar-wrap" style={{ width: rendered, height: rendered }}>
|
||
<svg
|
||
viewBox={`0 0 ${vb} ${vb}`}
|
||
width={rendered}
|
||
height={rendered}
|
||
role="img"
|
||
aria-label={`Score fingerprint: ${RADAR_AXES.map((ax, i) => `${ax} ${scores[i]}`).join(', ')} (0–100 percentile)`}
|
||
>
|
||
{[50, 100].map((ring) => (
|
||
<polygon key={ring} points={poly(Array(5).fill(ring))} fill="none" stroke="var(--grid)" strokeWidth="1" />
|
||
))}
|
||
{RADAR_AXES.map((ax, i) => {
|
||
const [x, y] = pt(100, i);
|
||
return <line key={ax} x1={c} y1={c} x2={x} y2={y} stroke="var(--grid)" strokeWidth="1" />;
|
||
})}
|
||
{reference && (
|
||
<polygon points={poly(reference)} fill="none" stroke="var(--ink-3)" strokeWidth="1.5" opacity="0.85" />
|
||
)}
|
||
<polygon
|
||
points={poly(scores)}
|
||
fill="var(--up)" fillOpacity="0.13"
|
||
stroke="var(--up)" strokeWidth="2" strokeLinejoin="round"
|
||
/>
|
||
{scores.map((v, i) => {
|
||
const [x, y] = pt(v, i);
|
||
return (
|
||
<circle
|
||
key={i} cx={x} cy={y} r={hover === i ? 4.5 : 3}
|
||
fill="var(--up)" stroke="var(--surface)" strokeWidth="1.5"
|
||
/>
|
||
);
|
||
})}
|
||
{labels && RADAR_AXES.map((ax, i) => {
|
||
const a = ((-90 + i * 72) * Math.PI) / 180;
|
||
const x = c + Math.cos(a) * (r + 14);
|
||
const y = c + Math.sin(a) * (r + 14);
|
||
const anchor = Math.cos(a) > 0.3 ? 'start' : Math.cos(a) < -0.3 ? 'end' : 'middle';
|
||
const dy = Math.sin(a) < -0.3 ? -2 : Math.sin(a) > 0.3 ? 9 : 4;
|
||
return (
|
||
<text key={ax} x={x} y={y + dy} textAnchor={anchor} className="hzn-radar-axis">
|
||
{ax} <tspan className="hzn-radar-axisval">{scores[i]}</tspan>
|
||
</text>
|
||
);
|
||
})}
|
||
{/* hit targets on top — comfortably larger than the dots, keyboard-reachable */}
|
||
{scores.map((v, i) => {
|
||
const [x, y] = pt(v, i);
|
||
return (
|
||
<circle
|
||
key={`hit-${i}`} cx={x} cy={y} r="13"
|
||
fill="transparent" style={{ cursor: 'default', outline: 'none' }}
|
||
tabIndex={0}
|
||
aria-label={`${RADAR_AXES_FULL[i]}: ${scores[i]}th percentile${reference ? `, live-setup median ${reference[i]}` : ''}`}
|
||
onMouseEnter={() => setHover(i)}
|
||
onMouseLeave={() => setHover(null)}
|
||
onFocus={() => setHover(i)}
|
||
onBlur={() => setHover(null)}
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
{hover !== null && tip && (
|
||
<div
|
||
className="hzn-radar-tip"
|
||
style={{ left: `${(tip[0] / vb) * 100}%`, top: `${(tip[1] / vb) * 100}%` }}
|
||
>
|
||
<b>{RADAR_AXES_FULL[hover]}</b>
|
||
<span className="num">{scores[hover]}th %ile</span>
|
||
{reference && <i className="num">live-setup median {reference[hover]}</i>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Trade chart — price since entry with entry / trailing stop / target */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
type Position = (typeof positions)[number];
|
||
|
||
function TradeChart({ p }: { p: Position }) {
|
||
const w = 560; const h = 150;
|
||
const padL = 8; const padR = 96; const padT = 10; const padB = 12;
|
||
const isShort = p.direction === 'short';
|
||
const vals = [...p.spark, ...p.stopTrail, p.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 - p.target <= range * 0.9 : p.target - hi <= range * 0.9;
|
||
if (targetFits) { lo = Math.min(lo, p.target); hi = Math.max(hi, p.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 / (p.spark.length - 1)) * plotW;
|
||
const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
|
||
const pricePath = p.spark.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
|
||
let stopPath = `M${px(0).toFixed(1)},${py(p.stopTrail[0]).toFixed(1)}`;
|
||
for (let i = 1; i < p.stopTrail.length; i++) {
|
||
stopPath += `H${px(i).toFixed(1)}V${py(p.stopTrail[i]).toFixed(1)}`;
|
||
}
|
||
const col = p.r >= 0 ? 'var(--up)' : 'var(--down)';
|
||
const labelX = w - padR + 10;
|
||
const entryY = py(p.entry);
|
||
let stopY = py(p.stopTrail[p.stopTrail.length - 1]);
|
||
if (Math.abs(stopY - entryY) < 11) stopY = entryY + (stopY >= entryY ? 11 : -11);
|
||
const lastI = p.spark.length - 1;
|
||
return (
|
||
<svg
|
||
viewBox={`0 0 ${w} ${h}`}
|
||
className="hzn-tradechart"
|
||
role="img"
|
||
aria-label={`${p.symbol}: price ${fmt(p.current)}, entry ${fmt(p.entry)}, trailing stop ${fmt(p.stop)}, target ${fmt(p.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="hzn-lvl">entry {fmt(p.entry)}</text>
|
||
<path d={stopPath} fill="none" stroke="var(--down)" strokeWidth="1.5" opacity="0.85" />
|
||
<text x={labelX} y={stopY + 3.5} className="hzn-lvl hzn-lvl-down">stop {fmt(p.stop)}</text>
|
||
{targetFits ? (
|
||
<>
|
||
<line x1={padL} x2={w - padR} y1={py(p.target)} y2={py(p.target)} stroke="var(--up)" strokeWidth="1" opacity="0.6" />
|
||
<text x={labelX} y={py(p.target) + 3.5} className="hzn-lvl hzn-lvl-up">target {fmt(p.target)}</text>
|
||
</>
|
||
) : (
|
||
<text x={labelX} y={isShort ? h - padB : padT + 4} className="hzn-lvl hzn-lvl-up">
|
||
target {fmt(p.target)} {isShort ? '↓' : '↑'}
|
||
</text>
|
||
)}
|
||
<path d={pricePath} fill="none" stroke={col} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||
<circle cx={px(lastI)} cy={py(p.spark[lastI])} r="4" fill={col} stroke="var(--surface)" strokeWidth="2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* The price rail — stop → entry → current → target, laid out spatially */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function PriceRail({ s }: { s: typeof heroSetup }) {
|
||
const lo = s.stop - (s.target - s.stop) * 0.06;
|
||
const hi = s.target + (s.target - s.stop) * 0.06;
|
||
const pct = (v: number) => ((v - lo) / (hi - lo)) * 100;
|
||
const rNow = (s.current - s.entry) / (s.entry - s.stop);
|
||
return (
|
||
<div className="hzn-rail">
|
||
<div className="hzn-rail-track" />
|
||
<div
|
||
className="hzn-rail-risk"
|
||
style={{ left: `${pct(s.stop)}%`, width: `${pct(s.entry) - pct(s.stop)}%` }}
|
||
/>
|
||
<div
|
||
className="hzn-rail-progress"
|
||
style={{ left: `${pct(s.entry)}%`, width: `${pct(s.current) - pct(s.entry)}%` }}
|
||
/>
|
||
{/* markers */}
|
||
<div className="hzn-rail-mark" style={{ left: `${pct(s.stop)}%` }}>
|
||
<span className="hzn-rail-tick" style={{ background: 'var(--down)' }} />
|
||
<span className="hzn-rail-label">
|
||
<em>stop</em>
|
||
<b>{fmt(s.stop)}</b>
|
||
<i>−1R</i>
|
||
</span>
|
||
</div>
|
||
<div className="hzn-rail-mark" style={{ left: `${pct(s.entry)}%` }}>
|
||
<span className="hzn-rail-tick" style={{ background: 'var(--ink-2)' }} />
|
||
<span className="hzn-rail-label">
|
||
<em>entry</em>
|
||
<b>{fmt(s.entry)}</b>
|
||
</span>
|
||
</div>
|
||
<div className="hzn-rail-mark hzn-rail-now" style={{ left: `${pct(s.current)}%` }}>
|
||
<span className="hzn-rail-dot" />
|
||
<span className="hzn-rail-label hzn-rail-label-top">
|
||
<em>now</em>
|
||
<b>{fmt(s.current)}</b>
|
||
<i>{signed(rNow)}R</i>
|
||
</span>
|
||
</div>
|
||
<div className="hzn-rail-mark" style={{ left: `${pct(s.target)}%` }}>
|
||
<span className="hzn-rail-ring" />
|
||
<span className="hzn-rail-label">
|
||
<em>target</em>
|
||
<b>{fmt(s.target)}</b>
|
||
<i>+{fmt(s.rr, 1)}R</i>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Performance chart — indexed to 100, crosshair + tooltip on hover */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const CHART = { w: 760, h: 220, top: 14, right: 64, bottom: 26, left: 40 };
|
||
|
||
function PerfChart() {
|
||
const [hover, setHover] = useState<number | null>(null);
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
|
||
const { w, h, top, right, bottom, left } = CHART;
|
||
const plotW = w - left - right;
|
||
const plotH = h - top - bottom;
|
||
const yMin = 95;
|
||
const yMax = 130;
|
||
const px = (i: number) => left + (i / (perfPortfolio.length - 1)) * plotW;
|
||
const py = (v: number) => top + plotH - ((v - yMin) / (yMax - yMin)) * plotH;
|
||
const line = (data: number[]) =>
|
||
data.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
|
||
|
||
const yTicks = [100, 110, 120, 130];
|
||
const xTicks = useMemo(
|
||
() => [0, 5, 10, 15, 20, 25].map((i) => ({ i, label: perfDates[i] })),
|
||
[],
|
||
);
|
||
|
||
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||
const rect = svgRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
const x = ((e.clientX - rect.left) / rect.width) * w;
|
||
const i = Math.round(((x - left) / plotW) * (perfPortfolio.length - 1));
|
||
setHover(Math.max(0, Math.min(perfPortfolio.length - 1, i)));
|
||
};
|
||
|
||
const last = perfPortfolio.length - 1;
|
||
|
||
return (
|
||
<div className="hzn-chart">
|
||
<div className="hzn-chart-head">
|
||
<div>
|
||
<p className="hzn-eyebrow">Performance</p>
|
||
<p className="hzn-chart-title">Paper book vs S&P 500 · indexed to 100 · since January</p>
|
||
</div>
|
||
<div className="hzn-legend">
|
||
<span><i style={{ background: 'var(--up)' }} /> Book</span>
|
||
<span><i style={{ background: 'var(--ink-3)' }} /> S&P 500</span>
|
||
</div>
|
||
</div>
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${w} ${h}`}
|
||
className="hzn-chart-svg"
|
||
onMouseMove={onMove}
|
||
onMouseLeave={() => setHover(null)}
|
||
role="img"
|
||
aria-label="Paper book up 26.4% versus S&P 500 up 8.2% since January, indexed to 100"
|
||
>
|
||
{yTicks.map((t) => (
|
||
<g key={t}>
|
||
<line x1={left} x2={w - right} y1={py(t)} y2={py(t)} stroke="var(--grid)" strokeWidth="1" />
|
||
<text x={left - 8} y={py(t) + 3.5} textAnchor="end" className="hzn-axis">{t}</text>
|
||
</g>
|
||
))}
|
||
{xTicks.map(({ i, label }) => (
|
||
<text key={i} x={px(i)} y={h - 6} textAnchor="middle" className="hzn-axis">{label}</text>
|
||
))}
|
||
<path d={line(perfSpy)} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" />
|
||
<path d={line(perfPortfolio)} fill="none" stroke="var(--up)" strokeWidth="2" strokeLinejoin="round" />
|
||
{hover !== null && (
|
||
<g>
|
||
<line x1={px(hover)} x2={px(hover)} y1={top} y2={top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
|
||
<circle cx={px(hover)} cy={py(perfPortfolio[hover])} r="4.5" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" />
|
||
<circle cx={px(hover)} cy={py(perfSpy[hover])} r="4.5" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" />
|
||
</g>
|
||
)}
|
||
{/* direct end labels */}
|
||
<circle cx={px(last)} cy={py(perfPortfolio[last])} r="4" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" />
|
||
<circle cx={px(last)} cy={py(perfSpy[last])} r="4" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" />
|
||
<text x={px(last) + 10} y={py(perfPortfolio[last]) + 4} className="hzn-endlabel">{fmt(perfPortfolio[last], 1)}</text>
|
||
<text x={px(last) + 10} y={py(perfSpy[last]) + 4} className="hzn-endlabel hzn-endlabel-dim">{fmt(perfSpy[last], 1)}</text>
|
||
</svg>
|
||
<div className="hzn-chart-tip" aria-live="polite">
|
||
{hover !== null
|
||
? <>week of {perfDates[hover]} — book <b>{fmt(perfPortfolio[hover], 1)}</b> · S&P 500 <b>{fmt(perfSpy[hover], 1)}</b></>
|
||
: <>hover for weekly values</>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Ticker drill-down — candlesticks + volume + trade levels */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function TickerPanel() {
|
||
const [hover, setHover] = useState<number | null>(null);
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
|
||
const w = 900; const padT = 10; const padB = 20; const padL = 8; const padR = 96;
|
||
const priceH = 290; const volH = 54; const gap = 16;
|
||
const H = padT + priceH + gap + volH + padB;
|
||
const n = vrtCandles.length;
|
||
const slot = (w - padL - padR) / n;
|
||
const bodyW = Math.min(7, slot * 0.62);
|
||
|
||
const hi = Math.max(...vrtCandles.map((b) => b.h), heroSetup.target + 1.5);
|
||
const lo = Math.min(...vrtCandles.map((b) => b.l)) - 1;
|
||
const maxV = Math.max(...vrtCandles.map((b) => b.v));
|
||
const px = (i: number) => padL + (i + 0.5) * slot;
|
||
const py = (v: number) => padT + priceH - ((v - lo) / (hi - lo)) * priceH;
|
||
const vy = (v: number) => padT + priceH + gap + volH - (v / maxV) * volH;
|
||
|
||
// Skip ticks whose right-gutter label would collide with a level label.
|
||
const levelYs = [
|
||
heroSetup.entry, heroSetup.stop, heroSetup.target,
|
||
tickerLevels.resistance, (tickerLevels.supportLo + tickerLevels.supportHi) / 2,
|
||
];
|
||
const yTicks = [75, 85, 95, 105].filter(
|
||
(t) => t > lo && t < hi
|
||
&& levelYs.every((l) => Math.abs(((t - l) / (hi - lo)) * priceH) >= 13),
|
||
);
|
||
const monthTicks = vrtCandles
|
||
.map((b, i) => ({ b, i }))
|
||
.filter(({ b, i }) => i === 0 || b.d.getMonth() !== vrtCandles[i - 1].d.getMonth())
|
||
.map(({ b, i }) => ({ i, label: b.d.toLocaleDateString('en-US', { month: 'short' }) }));
|
||
|
||
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||
const rect = svgRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
const x = ((e.clientX - rect.left) / rect.width) * w;
|
||
const i = Math.floor((x - padL) / slot);
|
||
setHover(Math.max(0, Math.min(n - 1, i)));
|
||
};
|
||
|
||
const labelX = w - padR + 10;
|
||
const hb = hover !== null ? vrtCandles[hover] : null;
|
||
|
||
return (
|
||
<section className="hzn-chart hzn-ticker" aria-label="Ticker drill-down: VRT">
|
||
<div className="hzn-ticker-top">
|
||
<div>
|
||
<p className="hzn-eyebrow">Drill-down · Ticker</p>
|
||
<p className="hzn-tk-title">
|
||
<b>VRT</b>
|
||
<span>Vertiv Holdings</span>
|
||
</p>
|
||
<p className="hzn-tk-price">
|
||
<span className="num">94.30</span>
|
||
<b className="hzn-up num">+1.9%</b>
|
||
<i>today</i>
|
||
</p>
|
||
<div className="hzn-tk-chips">
|
||
<span className="hzn-chip">composite score 82</span>
|
||
<span className="hzn-chip">ATR 2.90 · 3.1%</span>
|
||
<span className="hzn-chip">earnings Aug 5</span>
|
||
</div>
|
||
</div>
|
||
<div className="hzn-tk-radar">
|
||
<RadarChart scores={heroScores} reference={liveMedian} size={128} labels />
|
||
<p className="hzn-legend">
|
||
<span><i style={{ background: 'var(--up)' }} /> VRT</span>
|
||
<span><i style={{ background: 'var(--ink-3)' }} /> live-setup median</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hzn-tk-bars">
|
||
<nav className="hzn-tk-tabs" aria-label="Ticker sections">
|
||
<a className="hzn-tk-tab-on" href="#">Analysis</a>
|
||
<a href="#">Indicators</a>
|
||
<a href="#">S/R levels</a>
|
||
<a href="#">Sentiment</a>
|
||
<a href="#">Fundamentals</a>
|
||
</nav>
|
||
<div className="hzn-tk-ranges" role="group" aria-label="Time range">
|
||
{(['1M', '3M', '6M', 'YTD', '1Y', 'All'] as const).map((r) => (
|
||
<button key={r} className={r === '3M' ? 'hzn-tk-range-on' : ''}>{r}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${w} ${H}`}
|
||
className="hzn-chart-svg"
|
||
onMouseMove={onMove}
|
||
onMouseLeave={() => setHover(null)}
|
||
role="img"
|
||
aria-label="Daily candlestick chart for VRT with entry, trailing stop, target, support and resistance levels"
|
||
>
|
||
{yTicks.map((t) => (
|
||
<g key={t}>
|
||
<line x1={padL} x2={w - padR} y1={py(t)} y2={py(t)} stroke="var(--grid)" strokeWidth="1" />
|
||
<text x={labelX} y={py(t) + 3.5} className="hzn-axis">{t}</text>
|
||
</g>
|
||
))}
|
||
{monthTicks.map(({ i, label }) => (
|
||
<text key={label + i} x={px(i)} y={H - 5} textAnchor="middle" className="hzn-axis">{label}</text>
|
||
))}
|
||
|
||
{/* support zone + resistance (nearest S/R only — full list stays in its tab) */}
|
||
<rect
|
||
x={padL} width={w - padR - padL}
|
||
y={py(tickerLevels.supportHi)}
|
||
height={py(tickerLevels.supportLo) - py(tickerLevels.supportHi)}
|
||
fill="var(--up)" opacity="0.07"
|
||
/>
|
||
<text x={labelX} y={py((tickerLevels.supportLo + tickerLevels.supportHi) / 2) + 3.5} className="hzn-lvl">
|
||
sup {fmt(tickerLevels.supportLo, 1)}–{fmt(tickerLevels.supportHi, 1)}
|
||
</text>
|
||
<line x1={padL} x2={w - padR} y1={py(tickerLevels.resistance)} y2={py(tickerLevels.resistance)} stroke="var(--ink-3)" strokeWidth="1" opacity="0.7" />
|
||
<text x={labelX} y={py(tickerLevels.resistance) + 3.5} className="hzn-lvl">resist {fmt(tickerLevels.resistance)}</text>
|
||
|
||
{/* trade levels */}
|
||
<line x1={padL} x2={w - padR} y1={py(heroSetup.entry)} y2={py(heroSetup.entry)} stroke="var(--ink-2)" strokeWidth="1" opacity="0.55" />
|
||
<text x={labelX} y={py(heroSetup.entry) + 3.5} className="hzn-lvl hzn-lvl-ink">entry {fmt(heroSetup.entry)}</text>
|
||
<line x1={padL} x2={w - padR} y1={py(heroSetup.stop)} y2={py(heroSetup.stop)} stroke="var(--down)" strokeWidth="1" opacity="0.8" />
|
||
<text x={labelX} y={py(heroSetup.stop) + 3.5} className="hzn-lvl hzn-lvl-down">stop {fmt(heroSetup.stop)}</text>
|
||
<line x1={padL} x2={w - padR} y1={py(heroSetup.target)} y2={py(heroSetup.target)} stroke="var(--up)" strokeWidth="1" opacity="0.8" />
|
||
<text x={labelX} y={py(heroSetup.target) + 3.5} className="hzn-lvl hzn-lvl-up">target {fmt(heroSetup.target)}</text>
|
||
|
||
{/* candles */}
|
||
{vrtCandles.map((b, i) => {
|
||
const up = b.c >= b.o;
|
||
const col = up ? 'var(--up)' : 'var(--down)';
|
||
const bodyY = py(Math.max(b.o, b.c));
|
||
const bodyH = Math.max(1.5, Math.abs(py(b.o) - py(b.c)));
|
||
return (
|
||
<g key={i} opacity={hover === null || hover === i ? 1 : 0.55}>
|
||
<line x1={px(i)} x2={px(i)} y1={py(b.h)} y2={py(b.l)} stroke={col} strokeWidth="1" />
|
||
<rect x={px(i) - bodyW / 2} y={bodyY} width={bodyW} height={bodyH} fill={col} rx="1" />
|
||
<rect x={px(i) - bodyW / 2} y={vy(b.v)} width={bodyW} height={padT + priceH + gap + volH - vy(b.v)} fill="var(--ink-3)" opacity="0.55" rx="1" />
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
{hover !== null && (
|
||
<line x1={px(hover)} x2={px(hover)} y1={padT} y2={padT + priceH + gap + volH} stroke="var(--ink-3)" strokeWidth="1" opacity="0.6" />
|
||
)}
|
||
</svg>
|
||
|
||
<div className="hzn-chart-tip" aria-live="polite">
|
||
{hb ? (
|
||
<>
|
||
{hb.d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} — O <b>{fmt(hb.o)}</b> H <b>{fmt(hb.h)}</b> L <b>{fmt(hb.l)}</b> C <b>{fmt(hb.c)}</b> · vol <b>{(hb.v / 1e6).toFixed(1)}M</b>
|
||
</>
|
||
) : (
|
||
<>hover for daily OHLC · volume below</>
|
||
)}
|
||
</div>
|
||
|
||
<p className="hzn-fresh">
|
||
<span><i /> prices 2h ago</span>
|
||
<span><i /> indicators 2h ago</span>
|
||
<span><i /> sentiment 6h ago</span>
|
||
<span><i /> fundamentals 3d ago</span>
|
||
<a href="#">manage data →</a>
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Page */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
export default function DesignHorizonPage() {
|
||
const [expanded, setExpanded] = useState<string | null>('ANET');
|
||
const [showTicker, setShowTicker] = useState(true);
|
||
|
||
return (
|
||
<div className="hzn">
|
||
<style>{CSS}</style>
|
||
|
||
{/* atmosphere — stays behind everything, never carries data */}
|
||
<div className="hzn-stars" aria-hidden="true" />
|
||
<div className="hzn-horizon" aria-hidden="true">
|
||
<div className="hzn-rim" />
|
||
<div className="hzn-planet" />
|
||
</div>
|
||
|
||
<div className="hzn-page">
|
||
{/* command bar */}
|
||
<header className="hzn-bar">
|
||
<span className="hzn-wordmark">SIGNAL<i /></span>
|
||
<nav className="hzn-nav" aria-label="Sections">
|
||
<a className="hzn-nav-on" href="#">Overview</a>
|
||
<a href="#">Market</a>
|
||
<a href="#">Signals</a>
|
||
<a href="#">Regime</a>
|
||
</nav>
|
||
<div className="hzn-bar-right">
|
||
<span className="hzn-regime"><i /> Neutral regime · SPY above 50-day, breadth mixed</span>
|
||
<span className="hzn-mock">mockup · static data</span>
|
||
</div>
|
||
</header>
|
||
|
||
{/* the verdict */}
|
||
<section className="hzn-hero">
|
||
<p className="hzn-eyebrow">Thursday, July 9</p>
|
||
<h1>One setup clears the gate today.</h1>
|
||
<p className="hzn-hero-sub">
|
||
3 qualified of 14 live setups · 4 open positions · <b className="hzn-up">+2.3R</b> unrealized
|
||
</p>
|
||
</section>
|
||
|
||
{/* focal setup card */}
|
||
<section className="hzn-focus" aria-label="Top qualified setup">
|
||
<div className="hzn-focus-head">
|
||
<div className="hzn-focus-id">
|
||
<span className="hzn-pick">top pick</span>
|
||
<h2>{heroSetup.symbol}</h2>
|
||
<div>
|
||
<p className="hzn-focus-name">{heroSetup.name}</p>
|
||
<p className="hzn-focus-tags">
|
||
<DirTag direction={heroSetup.direction} />
|
||
<span className="hzn-chip">conviction {heroSetup.conviction}</span>
|
||
<span className="hzn-chip">residual momentum {heroSetup.residualPct}th %ile</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="hzn-focus-stats">
|
||
<div>
|
||
<p className="hzn-stat-label">reward / risk</p>
|
||
<p className="hzn-stat-value">{fmt(heroSetup.rr, 1)}<span> : 1</span></p>
|
||
</div>
|
||
<div>
|
||
<p className="hzn-stat-label">target probability</p>
|
||
<p className="hzn-stat-value">{heroSetup.targetProb}<span>%</span></p>
|
||
<div className="hzn-meter"><span style={{ width: `${heroSetup.targetProb}%` }} /></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<PriceRail s={heroSetup} />
|
||
|
||
<div className="hzn-focus-foot">
|
||
<span className="hzn-note">cleared the expected-value gate · {heroSetup.sizeHint}</span>
|
||
<span className="hzn-actions">
|
||
<button className="hzn-btn" onClick={() => setShowTicker((v) => !v)} aria-expanded={showTicker}>
|
||
{showTicker ? 'Hide ticker details' : 'Ticker details'}
|
||
</button>
|
||
<a className="hzn-btn hzn-btn-ghost" href="#">All 14 setups</a>
|
||
</span>
|
||
</div>
|
||
</section>
|
||
|
||
{showTicker && <TickerPanel />}
|
||
|
||
{/* KPI band */}
|
||
<section className="hzn-kpis" aria-label="Account status">
|
||
<div className="hzn-tile">
|
||
<p className="hzn-stat-label">open risk</p>
|
||
<p className="hzn-stat-value">$1,240</p>
|
||
<p className="hzn-tile-sub">4 open · distance to stops</p>
|
||
</div>
|
||
<div className="hzn-tile">
|
||
<p className="hzn-stat-label">unrealized</p>
|
||
<p className="hzn-stat-value hzn-up">+2.3R</p>
|
||
<p className="hzn-tile-sub">+$1,875 · 3 up, 1 down</p>
|
||
</div>
|
||
<div className="hzn-tile hzn-tile-spark">
|
||
<div>
|
||
<p className="hzn-stat-label">alpha vs S&P 500</p>
|
||
<p className="hzn-stat-value hzn-up">+$642</p>
|
||
<p className="hzn-tile-sub">open trades vs buy-and-hold SPY</p>
|
||
</div>
|
||
<Spark data={alphaSpark} color="var(--up)" />
|
||
</div>
|
||
<div className="hzn-tile">
|
||
<p className="hzn-stat-label">gate this week</p>
|
||
<p className="hzn-stat-value">3<span className="hzn-stat-dim"> / 14</span></p>
|
||
<p className="hzn-tile-sub">setups clearing the EV gate</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="hzn-cols">
|
||
{/* open positions — diverging R bars, expandable rows */}
|
||
<section className="hzn-panel" aria-label="Open positions">
|
||
<div className="hzn-panel-head">
|
||
<p className="hzn-eyebrow">Open positions</p>
|
||
<p className="hzn-panel-hint">R-multiple to entry · select a row for detail</p>
|
||
</div>
|
||
<ul className="hzn-poslist">
|
||
{positions.map((p) => {
|
||
const open = expanded === p.symbol;
|
||
return (
|
||
<li key={p.symbol} className={open ? 'hzn-pos-open' : ''}>
|
||
<button
|
||
className="hzn-pos"
|
||
onClick={() => setExpanded(open ? null : p.symbol)}
|
||
aria-expanded={open}
|
||
>
|
||
<span className="hzn-pos-id">
|
||
<b>{p.symbol}</b>
|
||
<DirTag direction={p.direction} />
|
||
</span>
|
||
<span className="hzn-pos-px num">{fmt(p.entry)} → {fmt(p.current)}</span>
|
||
<RBar r={p.r} />
|
||
<span className={`hzn-pos-r num ${p.r >= 0 ? 'hzn-up' : 'hzn-down'}`}>{signed(p.r)}R</span>
|
||
<span className={`hzn-caret ${open ? 'hzn-caret-open' : ''}`} aria-hidden="true">▾</span>
|
||
</button>
|
||
{open && (
|
||
<div className="hzn-pos-detail">
|
||
<dl>
|
||
<div><dt>opened</dt><dd>{p.opened} · {p.shares} shares</dd></div>
|
||
<div><dt>stop</dt><dd className="num">{fmt(p.stop)} <i>{p.stopNote}</i></dd></div>
|
||
<div><dt>target</dt><dd className="num">{fmt(p.target)}</dd></div>
|
||
<div><dt>exit rule</dt><dd>{p.exit}</dd></div>
|
||
<div>
|
||
<dt>alpha vs SPY</dt>
|
||
<dd className={`num ${p.alphaUsd >= 0 ? 'hzn-up' : 'hzn-down'}`}>{`${p.alphaUsd >= 0 ? '+' : '−'}$${fmt(Math.abs(p.alphaUsd), 0)}`}</dd>
|
||
</div>
|
||
</dl>
|
||
<div className="hzn-pos-chart">
|
||
<p className="hzn-stat-label">since entry · entry / trailing stop / target</p>
|
||
<TradeChart p={p} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</section>
|
||
|
||
{/* radar — ranked next setups with reasons */}
|
||
<section className="hzn-panel" aria-label="Setup radar">
|
||
<div className="hzn-panel-head">
|
||
<p className="hzn-eyebrow">Radar</p>
|
||
<p className="hzn-panel-hint">ranked by strategy score · why each does or doesn't qualify</p>
|
||
</div>
|
||
<div className="hzn-cmp-row">
|
||
{qualifiedCompare.map((q) => (
|
||
<figure key={q.symbol} className="hzn-cmp-fig">
|
||
<RadarChart scores={q.scores} size={96} />
|
||
<figcaption>
|
||
<b>{q.symbol}</b>
|
||
<span className={q.tag === 'top pick' ? 'hzn-cmp-pick' : ''}>{q.tag}</span>
|
||
</figcaption>
|
||
</figure>
|
||
))}
|
||
<p className="hzn-cmp-axes">
|
||
the 3 qualified setups<br />
|
||
same axes as the ticker fingerprint · 0–100 %ile
|
||
</p>
|
||
</div>
|
||
<ul className="hzn-radar">
|
||
{radar.map((r) => (
|
||
<li key={r.symbol} className={r.qualified ? '' : 'hzn-radar-dim'}>
|
||
<span className="hzn-radar-rank num">{r.rank}</span>
|
||
<span className="hzn-radar-id">
|
||
<b>{r.symbol}</b>
|
||
<small>{r.name}</small>
|
||
</span>
|
||
<DirTag direction={r.direction} />
|
||
<span className="hzn-radar-mom">
|
||
<span className="hzn-radar-momtrack">
|
||
<i style={{ width: `${r.residual}%`, background: r.qualified ? 'var(--up)' : 'var(--ink-3)' }} />
|
||
</span>
|
||
<small className="num">{r.residual}%ile</small>
|
||
</span>
|
||
<span className="hzn-radar-rr num">{r.rr}</span>
|
||
<span className={`hzn-radar-note ${r.qualified ? 'hzn-radar-yes' : ''}`}>
|
||
{r.qualified ? '✓ ' : ''}{r.note}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
|
||
<PerfChart />
|
||
|
||
{/* watchlist strip */}
|
||
<section className="hzn-watch" aria-label="Watchlist">
|
||
<p className="hzn-eyebrow">Watchlist</p>
|
||
<div className="hzn-watch-chips">
|
||
{watchlist.map((c) => (
|
||
<a key={c.symbol} href="#" className="hzn-wchip">
|
||
<b>{c.symbol}</b>
|
||
<span className={`num ${c.pct > 0.05 ? 'hzn-up' : c.pct < -0.05 ? 'hzn-down' : ''}`}>
|
||
{c.pct > 0 ? '+' : ''}{fmt(c.pct)}%
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<footer className="hzn-foot">
|
||
HORIZON · design study v2 · static mock data, nothing live
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Styles — scoped under .hzn, independent of the app theme */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const CSS = `
|
||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||
|
||
.hzn {
|
||
/* tokens — chart colors validated on the dark surface (#11131c) */
|
||
--void: #0a0b11;
|
||
--surface: #11131c;
|
||
--panel: rgba(226, 231, 245, 0.032);
|
||
--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; /* marks */
|
||
--up-text: #6ec9db; /* small text tint */
|
||
--down: #e36a58;
|
||
--down-text: #ef9182;
|
||
--amber: #c67f22;
|
||
--amber-text: #dfa356;
|
||
--ember: #ff6a45; /* reserved: horizon, wordmark, top-pick */
|
||
|
||
position: relative;
|
||
z-index: 0;
|
||
min-height: 100vh;
|
||
background: var(--void);
|
||
color: var(--ink);
|
||
font-family: 'Instrument Sans', system-ui, sans-serif;
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
overflow-x: hidden;
|
||
}
|
||
.hzn h1, .hzn h2 { font-family: 'Space Grotesk', system-ui, sans-serif; }
|
||
.hzn .num { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||
.hzn a { color: inherit; text-decoration: none; }
|
||
.hzn :focus-visible { outline: 2px solid var(--up-text); outline-offset: 2px; border-radius: 4px; }
|
||
|
||
/* ------- atmosphere ------- */
|
||
.hzn-stars {
|
||
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||
background-image:
|
||
radial-gradient(1px 1px at 12% 18%, rgba(237,238,243,.55) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 68% 8%, rgba(237,238,243,.4) 50%, transparent 51%),
|
||
radial-gradient(1.5px 1.5px at 84% 32%, rgba(159,216,232,.5) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 33% 44%, rgba(237,238,243,.35) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 91% 64%, rgba(237,238,243,.3) 50%, transparent 51%),
|
||
radial-gradient(1.5px 1.5px at 22% 71%, rgba(237,238,243,.42) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 55% 27%, rgba(255,150,120,.35) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 45% 87%, rgba(237,238,243,.3) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 76% 82%, rgba(159,216,232,.35) 50%, transparent 51%),
|
||
radial-gradient(1px 1px at 5% 55%, rgba(237,238,243,.35) 50%, transparent 51%);
|
||
background-size: 900px 700px;
|
||
background-repeat: repeat;
|
||
opacity: .8;
|
||
}
|
||
.hzn-horizon {
|
||
position: fixed; left: 0; right: 0; bottom: 0; height: 24vh;
|
||
z-index: 0; pointer-events: none; overflow: hidden;
|
||
}
|
||
.hzn-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,.22), transparent 60%),
|
||
radial-gradient(ellipse 120% 26% at 50% 0%, #3f130b 0%, #260a06 32%, #130504 58%, #0a0b11 86%);
|
||
}
|
||
.hzn-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,.55), rgba(159,216,232,.16) 40%, transparent 60%);
|
||
filter: blur(1px);
|
||
box-shadow: 0 -14px 50px rgba(159,216,232,.10), 0 -50px 140px rgba(228,87,61,.08);
|
||
}
|
||
|
||
/* ------- layout ------- */
|
||
.hzn-page {
|
||
position: relative; z-index: 1;
|
||
max-width: 1180px; margin: 0 auto;
|
||
padding: 28px 32px 22vh;
|
||
display: flex; flex-direction: column; gap: 34px;
|
||
}
|
||
|
||
/* ------- command bar ------- */
|
||
.hzn-bar { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
|
||
.hzn-wordmark {
|
||
font-family: 'Space Grotesk', sans-serif; font-weight: 700;
|
||
letter-spacing: .42em; font-size: 15px;
|
||
}
|
||
.hzn-wordmark i {
|
||
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
||
background: var(--ember); margin-left: 2px;
|
||
box-shadow: 0 0 10px rgba(255,106,69,.7);
|
||
}
|
||
.hzn-nav { display: flex; gap: 22px; font-size: 13.5px; color: var(--ink-2); }
|
||
.hzn-nav a:hover { color: var(--ink); }
|
||
.hzn-nav-on { color: var(--ink); border-bottom: 1px solid var(--up); padding-bottom: 2px; }
|
||
.hzn-bar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||
.hzn-regime { font-size: 12.5px; color: var(--ink-2); display: inline-flex; align-items: center; gap: 8px; }
|
||
.hzn-regime i { width: 7px; height: 7px; border-radius: 50%; background: var(--amber-text); display: inline-block; }
|
||
.hzn-mock {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: .14em;
|
||
text-transform: uppercase; color: var(--ink-3);
|
||
border: 1px solid var(--panel-line); border-radius: 99px; padding: 3px 10px;
|
||
}
|
||
|
||
/* ------- hero ------- */
|
||
.hzn-eyebrow {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10.5px;
|
||
letter-spacing: .28em; text-transform: uppercase; color: var(--ink-3);
|
||
}
|
||
.hzn-hero { margin-top: 6px; }
|
||
.hzn-hero h1 {
|
||
font-size: clamp(28px, 4.2vw, 44px); font-weight: 600;
|
||
letter-spacing: -0.015em; margin-top: 10px;
|
||
}
|
||
.hzn-hero-sub { margin-top: 8px; color: var(--ink-2); font-size: 15px; }
|
||
.hzn-up { color: var(--up-text); }
|
||
.hzn-down { color: var(--down-text); }
|
||
|
||
/* ------- focal setup ------- */
|
||
.hzn-focus {
|
||
background: var(--panel-glass); border: 1px solid var(--panel-line);
|
||
border-radius: 16px; padding: 26px 28px 22px;
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
.hzn-focus-head { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
|
||
.hzn-focus-id { display: flex; align-items: flex-start; gap: 18px; }
|
||
.hzn-pick {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 9.5px; letter-spacing: .2em;
|
||
text-transform: uppercase; color: var(--ember);
|
||
border: 1px solid rgba(255,106,69,.4); border-radius: 99px;
|
||
padding: 3px 9px; margin-top: 10px; white-space: nowrap;
|
||
}
|
||
.hzn-focus h2 { font-size: 46px; font-weight: 700; letter-spacing: -0.01em; line-height: 1; }
|
||
.hzn-focus-name { color: var(--ink-2); font-size: 13.5px; margin-top: 4px; }
|
||
.hzn-focus-tags { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||
.hzn-tag {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 9.5px; font-weight: 600;
|
||
letter-spacing: .12em; text-transform: uppercase;
|
||
border-radius: 4px; padding: 2.5px 7px;
|
||
}
|
||
.hzn-tag-long { color: var(--up-text); background: rgba(47,157,178,.14); }
|
||
.hzn-tag-short { color: var(--down-text); background: rgba(227,106,88,.14); }
|
||
.hzn-chip {
|
||
font-size: 11.5px; color: var(--ink-2);
|
||
border: 1px solid var(--panel-line); border-radius: 99px; padding: 2.5px 10px;
|
||
}
|
||
.hzn-focus-stats { display: flex; gap: 40px; text-align: right; }
|
||
.hzn-stat-label {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10px;
|
||
letter-spacing: .18em; text-transform: uppercase; color: var(--ink-3);
|
||
}
|
||
.hzn-stat-value {
|
||
font-family: 'Space Grotesk', sans-serif; font-size: 30px; font-weight: 600;
|
||
margin-top: 4px; line-height: 1.1;
|
||
}
|
||
.hzn-stat-value span { font-size: 17px; color: var(--ink-2); }
|
||
.hzn-stat-dim { color: var(--ink-3); }
|
||
.hzn-meter {
|
||
width: 120px; height: 4px; border-radius: 2px; margin-top: 8px; margin-left: auto;
|
||
background: rgba(47,157,178,.16);
|
||
}
|
||
.hzn-meter span { display: block; height: 100%; border-radius: 2px; background: var(--up); }
|
||
|
||
/* ------- price rail ------- */
|
||
.hzn-rail { position: relative; height: 108px; margin: 34px 8px 4px; }
|
||
.hzn-rail-track {
|
||
position: absolute; left: 0; right: 0; top: 50%; height: 1px;
|
||
background: var(--grid);
|
||
}
|
||
.hzn-rail-risk {
|
||
position: absolute; top: calc(50% - 2px); height: 4px;
|
||
background: rgba(227,106,88,.16); border-radius: 2px;
|
||
}
|
||
.hzn-rail-progress {
|
||
position: absolute; top: calc(50% - 2px); height: 4px;
|
||
background: var(--up); border-radius: 2px;
|
||
}
|
||
.hzn-rail-mark { position: absolute; top: 0; bottom: 0; width: 0; }
|
||
.hzn-rail-tick {
|
||
position: absolute; top: calc(50% - 7px); left: -1px;
|
||
width: 2px; height: 14px; border-radius: 1px;
|
||
}
|
||
.hzn-rail-dot {
|
||
position: absolute; top: calc(50% - 6px); left: -6px;
|
||
width: 12px; height: 12px; border-radius: 50%;
|
||
background: var(--up); border: 2px solid var(--surface);
|
||
box-shadow: 0 0 0 1px var(--up), 0 0 14px rgba(110,201,219,.55);
|
||
}
|
||
.hzn-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);
|
||
}
|
||
.hzn-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;
|
||
}
|
||
.hzn-rail-label-top { top: auto; bottom: calc(50% + 16px); }
|
||
.hzn-rail-label em {
|
||
font-style: normal; font-family: 'IBM Plex Mono', monospace; font-size: 9.5px;
|
||
letter-spacing: .18em; text-transform: uppercase; color: var(--ink-3);
|
||
}
|
||
.hzn-rail-label b {
|
||
font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: 13px;
|
||
color: var(--ink);
|
||
}
|
||
.hzn-rail-label i {
|
||
font-style: normal; font-family: 'IBM Plex Mono', monospace; font-size: 10.5px;
|
||
color: var(--ink-2);
|
||
}
|
||
.hzn-rail-now .hzn-rail-label b { color: var(--up-text); }
|
||
|
||
.hzn-focus-foot {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
gap: 16px; flex-wrap: wrap; margin-top: 18px;
|
||
border-top: 1px solid var(--grid); padding-top: 16px;
|
||
}
|
||
.hzn-note { font-size: 12.5px; color: var(--ink-3); }
|
||
.hzn-actions { display: flex; gap: 10px; }
|
||
.hzn-btn {
|
||
font-size: 13px; font-weight: 600; border-radius: 8px; padding: 7px 16px;
|
||
background: rgba(47,157,178,.16); color: var(--up-text);
|
||
border: 1px solid rgba(47,157,178,.35);
|
||
transition: background .15s ease, border-color .15s ease;
|
||
font-family: inherit; cursor: pointer;
|
||
}
|
||
.hzn-btn:hover { background: rgba(47,157,178,.26); border-color: rgba(110,201,219,.5); }
|
||
.hzn-btn-ghost { background: transparent; color: var(--ink-2); border-color: var(--panel-line); }
|
||
.hzn-btn-ghost:hover { color: var(--ink); background: rgba(226,231,245,.04); }
|
||
|
||
/* ------- KPI band ------- */
|
||
.hzn-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||
.hzn-tile {
|
||
background: var(--panel-glass); border: 1px solid var(--panel-line);
|
||
border-radius: 14px; padding: 18px 20px;
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
.hzn-tile .hzn-stat-value { font-size: 26px; }
|
||
.hzn-tile-sub { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||
.hzn-tile-spark { display: flex; justify-content: space-between; align-items: flex-end; gap: 10px; }
|
||
|
||
/* ------- panels / columns ------- */
|
||
.hzn-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
|
||
.hzn-panel {
|
||
background: var(--panel-glass); border: 1px solid var(--panel-line);
|
||
border-radius: 14px; padding: 20px 22px;
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
.hzn-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
||
.hzn-panel-hint { font-size: 11.5px; color: var(--ink-3); }
|
||
|
||
/* positions */
|
||
.hzn-poslist { list-style: none; display: flex; flex-direction: column; }
|
||
.hzn-poslist > li { border-top: 1px solid var(--grid); }
|
||
.hzn-poslist > li:first-child { border-top: 0; }
|
||
.hzn-pos {
|
||
display: grid; grid-template-columns: 92px 1fr 130px 64px 16px;
|
||
align-items: center; gap: 12px;
|
||
width: 100%; text-align: left; background: none; border: 0; color: inherit;
|
||
font: inherit; padding: 11px 4px; cursor: pointer;
|
||
transition: background .15s ease; border-radius: 8px;
|
||
}
|
||
.hzn-pos:hover { background: rgba(226,231,245,.035); }
|
||
.hzn-pos-id { display: flex; align-items: center; gap: 8px; }
|
||
.hzn-pos-id b { font-weight: 600; }
|
||
.hzn-pos-px { font-size: 12px; color: var(--ink-2); }
|
||
.hzn-pos-r { text-align: right; font-size: 13px; font-weight: 600; }
|
||
.hzn-caret { color: var(--ink-3); font-size: 10px; transition: transform .15s ease; }
|
||
.hzn-caret-open { transform: rotate(180deg); }
|
||
.hzn-rbar { position: relative; height: 10px; }
|
||
.hzn-rbar-zero {
|
||
position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px;
|
||
background: var(--panel-line);
|
||
}
|
||
.hzn-rbar-fill { position: absolute; top: 0; bottom: 0; }
|
||
.hzn-pos-detail {
|
||
display: flex; flex-direction: column; gap: 16px;
|
||
padding: 6px 8px 16px; font-size: 12.5px;
|
||
}
|
||
.hzn-pos-detail dl { display: grid; grid-template-columns: repeat(3, minmax(0, auto)); gap: 10px 34px; }
|
||
.hzn-pos-detail dt {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 9.5px; letter-spacing: .16em;
|
||
text-transform: uppercase; color: var(--ink-3);
|
||
}
|
||
.hzn-pos-detail dd { margin-top: 2px; color: var(--ink); }
|
||
.hzn-pos-detail dd i { font-style: normal; color: var(--ink-3); font-size: 11px; margin-left: 6px; }
|
||
.hzn-pos-chart { width: 100%; }
|
||
.hzn-tradechart { width: 100%; height: auto; display: block; margin-top: 8px; }
|
||
.hzn-lvl {
|
||
fill: var(--ink-3); font-family: 'IBM Plex Mono', monospace; font-size: 10px;
|
||
}
|
||
.hzn-lvl-up { fill: var(--up-text); }
|
||
.hzn-lvl-down { fill: var(--down-text); }
|
||
.hzn-lvl-ink { fill: var(--ink-2); }
|
||
|
||
/* radar */
|
||
.hzn-radar { list-style: none; display: flex; flex-direction: column; }
|
||
.hzn-radar li {
|
||
display: grid; grid-template-columns: 20px 118px 44px 1fr 52px minmax(120px, 1.2fr);
|
||
align-items: center; gap: 10px;
|
||
padding: 10px 4px; border-top: 1px solid var(--grid);
|
||
border-radius: 8px; transition: background .15s ease;
|
||
}
|
||
.hzn-radar li:first-child { border-top: 0; }
|
||
.hzn-radar li:hover { background: rgba(226,231,245,.035); }
|
||
.hzn-radar-dim { opacity: .62; }
|
||
.hzn-radar-rank { color: var(--ink-3); font-size: 11px; }
|
||
.hzn-radar-id b { font-weight: 600; display: block; }
|
||
.hzn-radar-id small { color: var(--ink-3); font-size: 10.5px; display: block; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.hzn-radar-mom { display: flex; align-items: center; gap: 8px; }
|
||
.hzn-radar-momtrack {
|
||
flex: 1; height: 4px; border-radius: 2px; background: rgba(226,231,245,.07);
|
||
overflow: hidden;
|
||
}
|
||
.hzn-radar-momtrack i { display: block; height: 100%; border-radius: 2px; }
|
||
.hzn-radar-mom small { color: var(--ink-3); font-size: 10px; }
|
||
.hzn-radar-rr { text-align: right; font-size: 12.5px; color: var(--ink-2); }
|
||
.hzn-radar-note { font-size: 11.5px; color: var(--ink-3); text-align: right; }
|
||
.hzn-radar-yes { color: var(--up-text); }
|
||
|
||
/* ------- chart ------- */
|
||
.hzn-chart {
|
||
background: var(--panel-glass); border: 1px solid var(--panel-line);
|
||
border-radius: 14px; padding: 20px 22px 12px;
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
.hzn-chart-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
|
||
.hzn-chart-title { font-size: 13px; color: var(--ink-2); margin-top: 5px; }
|
||
.hzn-legend { display: flex; gap: 16px; font-size: 12px; color: var(--ink-2); }
|
||
.hzn-legend i {
|
||
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
|
||
margin-right: 6px; vertical-align: -1px;
|
||
}
|
||
.hzn-chart-svg { width: 100%; height: auto; margin-top: 10px; display: block; }
|
||
.hzn-axis { fill: var(--ink-3); font-family: 'IBM Plex Mono', monospace; font-size: 10px; }
|
||
.hzn-endlabel { fill: var(--ink); font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600; }
|
||
.hzn-endlabel-dim { fill: var(--ink-2); font-weight: 400; }
|
||
.hzn-chart-tip {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--ink-3);
|
||
padding: 8px 2px 4px; min-height: 28px;
|
||
}
|
||
.hzn-chart-tip b { color: var(--ink); font-weight: 500; }
|
||
|
||
/* ------- radar fingerprint ------- */
|
||
.hzn-radar-wrap { position: relative; display: inline-block; }
|
||
.hzn-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,.45);
|
||
font-size: 12px;
|
||
}
|
||
.hzn-radar-tip b { display: block; color: var(--ink); font-weight: 600; }
|
||
.hzn-radar-tip span { display: block; color: var(--up-text); font-size: 11px; margin-top: 1px; }
|
||
.hzn-radar-tip i { display: block; font-style: normal; color: var(--ink-3); font-size: 10px; margin-top: 1px; }
|
||
.hzn-radar-axis {
|
||
fill: var(--ink-3); font-family: 'IBM Plex Mono', monospace; font-size: 9.5px;
|
||
letter-spacing: .04em;
|
||
}
|
||
.hzn-radar-axisval { fill: var(--ink-2); font-weight: 600; }
|
||
.hzn-tk-radar { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||
.hzn-cmp-row {
|
||
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
|
||
border-bottom: 1px solid var(--grid); padding-bottom: 12px; margin-bottom: 10px;
|
||
}
|
||
.hzn-cmp-fig { text-align: center; }
|
||
.hzn-cmp-fig figcaption b { display: block; font-size: 13px; font-weight: 600; margin-top: -2px; }
|
||
.hzn-cmp-fig figcaption span {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 9px; letter-spacing: .12em;
|
||
text-transform: uppercase; color: var(--ink-3);
|
||
}
|
||
.hzn-cmp-fig .hzn-cmp-pick { color: var(--ember); }
|
||
.hzn-cmp-axes {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10px; line-height: 1.7;
|
||
color: var(--ink-3); margin-left: auto; text-align: right; max-width: 220px;
|
||
}
|
||
|
||
/* ------- ticker drill-down ------- */
|
||
.hzn-ticker-top { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||
.hzn-tk-title { display: flex; align-items: baseline; gap: 10px; margin-top: 8px; }
|
||
.hzn-tk-title b { font-family: 'Space Grotesk', sans-serif; font-size: 30px; font-weight: 700; }
|
||
.hzn-tk-title span { color: var(--ink-2); font-size: 13px; }
|
||
.hzn-tk-price { display: flex; align-items: baseline; gap: 10px; margin-top: 4px; }
|
||
.hzn-tk-price .num:first-child { font-size: 18px; color: var(--ink); }
|
||
.hzn-tk-price b { font-size: 13px; }
|
||
.hzn-tk-price i { font-style: normal; font-size: 11.5px; color: var(--ink-3); }
|
||
.hzn-tk-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
|
||
.hzn-tk-bars {
|
||
display: flex; justify-content: space-between; align-items: center; gap: 14px;
|
||
flex-wrap: wrap; margin-top: 16px; border-bottom: 1px solid var(--grid); padding-bottom: 10px;
|
||
}
|
||
.hzn-tk-tabs { display: flex; gap: 18px; font-size: 13px; color: var(--ink-2); }
|
||
.hzn-tk-tabs a:hover { color: var(--ink); }
|
||
.hzn-tk-tab-on { color: var(--ink); border-bottom: 1px solid var(--up); padding-bottom: 2px; }
|
||
.hzn-tk-ranges { display: flex; gap: 4px; }
|
||
.hzn-tk-ranges button {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10.5px; color: var(--ink-3);
|
||
background: none; border: 1px solid transparent; border-radius: 6px;
|
||
padding: 3px 8px; cursor: pointer;
|
||
}
|
||
.hzn-tk-ranges button:hover { color: var(--ink-2); }
|
||
.hzn-tk-ranges .hzn-tk-range-on {
|
||
color: var(--up-text); border-color: rgba(47,157,178,.35); background: rgba(47,157,178,.12);
|
||
}
|
||
.hzn-fresh {
|
||
display: flex; gap: 16px; flex-wrap: wrap; align-items: center;
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: .06em;
|
||
color: var(--ink-3); border-top: 1px solid var(--grid); padding-top: 10px; margin-top: 4px;
|
||
}
|
||
.hzn-fresh span { display: inline-flex; align-items: center; gap: 6px; }
|
||
.hzn-fresh i {
|
||
width: 5px; height: 5px; border-radius: 50%; background: var(--up); display: inline-block;
|
||
}
|
||
.hzn-fresh a { color: var(--ink-2); margin-left: auto; }
|
||
.hzn-fresh a:hover { color: var(--ink); }
|
||
|
||
/* ------- watchlist ------- */
|
||
.hzn-watch-chips { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
|
||
.hzn-wchip {
|
||
display: inline-flex; align-items: baseline; gap: 8px;
|
||
border: 1px solid var(--panel-line); border-radius: 99px;
|
||
padding: 6px 14px; font-size: 13px;
|
||
transition: border-color .15s ease, background .15s ease;
|
||
}
|
||
.hzn-wchip:hover { border-color: rgba(110,201,219,.4); background: rgba(226,231,245,.03); }
|
||
.hzn-wchip b { font-weight: 600; }
|
||
.hzn-wchip span { font-size: 11.5px; color: var(--ink-2); }
|
||
|
||
/* ------- footer ------- */
|
||
.hzn-foot {
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 10px;
|
||
letter-spacing: .3em; text-transform: uppercase; color: var(--ink-3);
|
||
text-align: center; margin-top: 10px;
|
||
}
|
||
|
||
/* ------- responsive ------- */
|
||
@media (max-width: 980px) {
|
||
.hzn-kpis { grid-template-columns: repeat(2, 1fr); }
|
||
.hzn-cols { grid-template-columns: 1fr; }
|
||
.hzn-radar li { grid-template-columns: 20px 110px 44px 1fr 48px; }
|
||
.hzn-radar-note { grid-column: 2 / -1; text-align: left; padding-bottom: 2px; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.hzn-page { padding: 20px 16px 24vh; }
|
||
.hzn-kpis { grid-template-columns: 1fr; }
|
||
.hzn-focus-stats { text-align: left; }
|
||
.hzn-meter { margin-left: 0; }
|
||
.hzn-pos { grid-template-columns: 86px 1fr 60px 14px; }
|
||
.hzn-pos-px { display: none; }
|
||
}
|
||
|
||
/* ------- motion ------- */
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.hzn-page { animation: hzn-rise .5s ease-out both; }
|
||
@keyframes hzn-rise {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
}
|
||
`;
|