Horizon redesign: space theme, visual dashboard, chart reskin
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m9s
Deploy / deploy (push) Successful in 36s

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:
2026-07-10 07:24:17 +02:00
co-authored by Claude Fable 5
parent 744ea4ddc4
commit 20f6981712
15 changed files with 3283 additions and 370 deletions
+295
View File
@@ -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 (0100), 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(', ')} (0100)`}
>
{[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>
);
}