safety(web-dashboard): theme-consistent chart colors, a11y attributes, WS reconnect, loading skeletons

- Replace all hardcoded hex colors in chart components with CSS custom
  properties (getCssVar helper), making charts respect theme changes
- Add scope="col" to all table headers (PositionsTable, OrdersTable,
  BacktestingDashboard trade list)
- Connect form labels to inputs via htmlFor/id pairs (OrderForm,
  BacktestingDashboard)
- Add role="alert" to validation error messages
- Add aria-label to cancel button, progressbar role to backtest progress
- Add WsManager.reconnect() method with backoff reset
- Add Reconnect button to StatusBar when WS disconnected
- Add shimmer skeleton loading states to CandlestickChart and PnLChart

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-22 09:22:34 +01:00
parent c7d336ec59
commit cab4fcda4c
10 changed files with 179 additions and 68 deletions

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef } from 'react';
import { createChart, CandlestickSeries, type IChartApi, type CandlestickData, type Time } from 'lightweight-charts';
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
interface Props {
symbol: string;
data?: CandlestickData<Time>[];
@@ -16,34 +20,40 @@ export function CandlestickChart({ symbol, data = [] }: Props) {
useEffect(() => {
if (!containerRef.current) return;
const bgCard = getCssVar('--color-bg-card');
const textSecondary = getCssVar('--color-text-secondary');
const border = getCssVar('--color-border');
const green = getCssVar('--color-green');
const red = getCssVar('--color-red');
const chart = createChart(containerRef.current, {
layout: {
background: { color: '#1e293b' },
textColor: '#94a3b8',
background: { color: bgCard },
textColor: textSecondary,
},
grid: {
vertLines: { color: '#334155' },
horzLines: { color: '#334155' },
vertLines: { color: border },
horzLines: { color: border },
},
crosshair: {
mode: 0, // Normal
},
timeScale: {
borderColor: '#334155',
borderColor: border,
timeVisible: true,
},
rightPriceScale: {
borderColor: '#334155',
borderColor: border,
},
});
const series = chart.addSeries(CandlestickSeries, {
upColor: '#22c55e',
downColor: '#ef4444',
borderUpColor: '#22c55e',
borderDownColor: '#ef4444',
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
upColor: green,
downColor: red,
borderUpColor: green,
borderDownColor: red,
wickUpColor: green,
wickDownColor: red,
});
chartRef.current = chart;
@@ -77,7 +87,25 @@ export function CandlestickChart({ symbol, data = [] }: Props) {
<span className="text-sm font-medium">{symbol}</span>
<span className="text-xs text-[var(--color-text-secondary)]">1m</span>
</div>
<div ref={containerRef} className="flex-1 min-h-0" />
<div className="relative flex-1 min-h-0">
<div ref={containerRef} className="absolute inset-0" />
{data.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-[var(--color-bg-card)]/80">
<div className="text-center">
<div className="flex gap-1 mb-2 justify-center">
{Array.from({ length: 8 }, (_, i) => (
<div
key={i}
className="w-2 rounded-sm bg-[var(--color-border)] animate-pulse"
style={{ height: `${16 + i * 4}px`, animationDelay: `${i * 100}ms` }}
/>
))}
</div>
<span className="text-xs text-[var(--color-text-secondary)]">Awaiting market data...</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,10 @@
import { useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
interface PnLPoint {
date: string;
pnl: number;
@@ -11,6 +16,12 @@ interface Props {
}
export function PnLChart({ data = [], title = 'P&L' }: Props) {
const colors = useMemo(() => ({
border: getCssVar('--color-border'),
textSecondary: getCssVar('--color-text-secondary'),
bgCard: getCssVar('--color-bg-card'),
accent: getCssVar('--color-accent'),
}), []);
return (
<div className="flex flex-col h-full">
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
@@ -20,16 +31,16 @@ export function PnLChart({ data = [], title = 'P&L' }: Props) {
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} />
<CartesianGrid strokeDasharray="3 3" stroke={colors.border} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: colors.textSecondary }} />
<YAxis
tick={{ fontSize: 10, fill: '#94a3b8' }}
tick={{ fontSize: 10, fill: colors.textSecondary }}
tickFormatter={(v: number) => `$${v.toLocaleString()}`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
backgroundColor: colors.bgCard,
border: `1px solid ${colors.border}`,
borderRadius: 4,
fontSize: 12,
}}
@@ -37,22 +48,33 @@ export function PnLChart({ data = [], title = 'P&L' }: Props) {
/>
<defs>
<linearGradient id="pnlGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
<stop offset="5%" stopColor={colors.accent} stopOpacity={0.3} />
<stop offset="95%" stopColor={colors.accent} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="pnl"
stroke="#3b82f6"
stroke={colors.accent}
fill="url(#pnlGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-sm text-[var(--color-text-secondary)]">
No P&L data
<div className="flex flex-col justify-end h-full p-4">
<div className="flex items-end gap-1 h-full">
{Array.from({ length: 16 }, (_, i) => (
<div
key={i}
className="flex-1 rounded-t-sm bg-[var(--color-border)] animate-pulse"
style={{ height: `${15 + Math.abs(Math.sin(i * 0.7)) * 60}%`, animationDelay: `${i * 80}ms` }}
/>
))}
</div>
<div className="text-center text-xs text-[var(--color-text-secondary)] mt-2">
No P&L data available
</div>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { useWebSocket } from '../../hooks/useWebSocket';
import { useAuth } from '../../hooks/useAuth';
import { getTokenClaims } from '../../lib/auth';
import { wsManager } from '../../lib/websocket';
const EMPTY_TOPICS: string[] = [];
@@ -19,7 +20,18 @@ export function StatusBar() {
}`}
aria-hidden="true"
/>
WS: {connected ? 'Connected' : 'Disconnected'}
WS: {connected ? 'Connected' : (
<>
Disconnected
<button
onClick={() => wsManager.reconnect()}
className="ml-1 px-1.5 py-0.5 rounded text-[var(--color-accent)] hover:bg-[var(--color-accent)]/10 transition-colors"
aria-label="Reconnect WebSocket"
>
Reconnect
</button>
</>
)}
</span>
{authenticated && claims && (
<span>User: {claims.sub}</span>

View File

@@ -1,5 +1,10 @@
import { useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
interface DrawdownPoint {
time: string;
drawdown: number;
@@ -10,6 +15,12 @@ interface Props {
}
export function DrawdownChart({ data = [] }: Props) {
const colors = useMemo(() => ({
border: getCssVar('--color-border'),
textSecondary: getCssVar('--color-text-secondary'),
bgCard: getCssVar('--color-bg-card'),
red: getCssVar('--color-red'),
}), []);
return (
<div className="flex flex-col h-full">
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
@@ -19,17 +30,17 @@ export function DrawdownChart({ data = [] }: Props) {
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#94a3b8' }} />
<CartesianGrid strokeDasharray="3 3" stroke={colors.border} />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: colors.textSecondary }} />
<YAxis
tick={{ fontSize: 10, fill: '#94a3b8' }}
tick={{ fontSize: 10, fill: colors.textSecondary }}
tickFormatter={(v: number) => `${v.toFixed(1)}%`}
domain={['dataMin', 0]}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
backgroundColor: colors.bgCard,
border: `1px solid ${colors.border}`,
borderRadius: 4,
fontSize: 12,
}}
@@ -37,8 +48,8 @@ export function DrawdownChart({ data = [] }: Props) {
<Area
type="monotone"
dataKey="drawdown"
stroke="#ef4444"
fill="#ef444433"
stroke={colors.red}
fill={`${colors.red}33`}
strokeWidth={2}
/>
</AreaChart>

View File

@@ -1,5 +1,10 @@
import { useMemo } from 'react';
import { RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts';
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
interface Props {
label: string;
value: number;
@@ -13,6 +18,10 @@ export function RiskGauge({
max = 100,
thresholds = { warn: 60, danger: 80 },
}: Props) {
const colors = useMemo(() => ({
border: getCssVar('--color-border'),
}), []);
const pct = Math.min((value / max) * 100, 100);
const color =
@@ -39,7 +48,7 @@ export function RiskGauge({
endAngle={0}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" cornerRadius={6} background={{ fill: '#334155' }} />
<RadialBar dataKey="value" cornerRadius={6} background={{ fill: colors.border }} />
</RadialBarChart>
<div className="text-center -mt-8">
<div className="text-lg font-bold" style={{ color }}>

View File

@@ -84,8 +84,9 @@ export function OrderForm() {
<form onSubmit={handleSubmit} className="p-3 space-y-3">
{/* Symbol */}
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">Symbol</label>
<label htmlFor="order-symbol" className="block text-xs text-[var(--color-text-secondary)] mb-1">Symbol</label>
<input
id="order-symbol"
type="text"
value={symbol}
onChange={(e) => {
@@ -101,7 +102,7 @@ export function OrderForm() {
}`}
/>
{validationErrors.symbol && (
<p className="text-xs text-[var(--color-red)] mt-0.5">{validationErrors.symbol}</p>
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.symbol}</p>
)}
</div>
@@ -140,8 +141,9 @@ export function OrderForm() {
{/* Type */}
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">Type</label>
<label htmlFor="order-type" className="block text-xs text-[var(--color-text-secondary)] mb-1">Type</label>
<select
id="order-type"
value={orderType}
onChange={(e) => setOrderType(e.target.value as 'market' | 'limit')}
className="w-full px-2 py-1.5 text-sm rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none"
@@ -153,8 +155,9 @@ export function OrderForm() {
{/* Quantity */}
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">Quantity</label>
<label htmlFor="order-quantity" className="block text-xs text-[var(--color-text-secondary)] mb-1">Quantity</label>
<input
id="order-quantity"
type="number"
min="0"
step="1"
@@ -170,15 +173,16 @@ export function OrderForm() {
}`}
/>
{validationErrors.quantity && (
<p className="text-xs text-[var(--color-red)] mt-0.5">{validationErrors.quantity}</p>
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.quantity}</p>
)}
</div>
{/* Price (limit only) */}
{orderType === 'limit' && (
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">Price</label>
<label htmlFor="order-price" className="block text-xs text-[var(--color-text-secondary)] mb-1">Price</label>
<input
id="order-price"
type="number"
min="0"
step="0.01"
@@ -194,7 +198,7 @@ export function OrderForm() {
}`}
/>
{validationErrors.price && (
<p className="text-xs text-[var(--color-red)] mt-0.5">{validationErrors.price}</p>
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.price}</p>
)}
</div>
)}
@@ -213,7 +217,7 @@ export function OrderForm() {
</button>
{submitOrder.isError && (
<p className="text-xs text-[var(--color-red)]">{submitOrder.error.message}</p>
<p className="text-xs text-[var(--color-red)]" role="alert">{submitOrder.error.message}</p>
)}
</form>
</div>

View File

@@ -20,14 +20,14 @@ export function OrdersTable({ orders = [], loading }: Props) {
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--color-text-secondary)] border-b border-[var(--color-border)]">
<th className="text-left px-3 py-2 font-medium">ID</th>
<th className="text-left px-3 py-2 font-medium">Symbol</th>
<th className="text-left px-3 py-2 font-medium">Side</th>
<th className="text-left px-3 py-2 font-medium">Type</th>
<th className="text-right px-3 py-2 font-medium">Qty</th>
<th className="text-right px-3 py-2 font-medium">Price</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-right px-3 py-2 font-medium">Action</th>
<th scope="col" className="text-left px-3 py-2 font-medium">ID</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Symbol</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Side</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Type</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Qty</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Price</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Status</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Action</th>
</tr>
</thead>
<tbody>
@@ -96,13 +96,14 @@ function OrderRow({ order }: { order: Order }) {
<button
onClick={() => cancelOrder.mutate()}
disabled={cancelOrder.isPending}
aria-label={`Cancel order ${order.order_id.slice(0, 8)}`}
className="px-2 py-0.5 text-xs rounded bg-[var(--color-red)]/20 text-[var(--color-red)] hover:bg-[var(--color-red)]/30 disabled:opacity-50"
>
{cancelOrder.isPending ? 'Cancelling...' : 'Cancel'}
</button>
)}
{cancelOrder.isError && (
<span className="text-xs text-[var(--color-red)]">
<span className="text-xs text-[var(--color-red)]" role="alert">
{cancelOrder.error.message}
</span>
)}

View File

@@ -19,12 +19,12 @@ export function PositionsTable({ positions = [], loading }: Props) {
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--color-text-secondary)] border-b border-[var(--color-border)]">
<th className="text-left px-3 py-2 font-medium">Symbol</th>
<th className="text-left px-3 py-2 font-medium">Side</th>
<th className="text-right px-3 py-2 font-medium">Qty</th>
<th className="text-right px-3 py-2 font-medium">Entry</th>
<th className="text-right px-3 py-2 font-medium">Market</th>
<th className="text-right px-3 py-2 font-medium">P&L</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Symbol</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Side</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Qty</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Entry</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Market</th>
<th scope="col" className="text-right px-3 py-2 font-medium">P&L</th>
</tr>
</thead>
<tbody>

View File

@@ -79,6 +79,22 @@ export class WsManager {
this._connected = false;
}
/** Manually trigger reconnection (resets backoff) */
reconnect(): void {
this.disconnect();
this.attempts = 0;
this.backoff = 1000;
this.connect();
}
get reconnectAttempts(): number {
return this.attempts;
}
get maxReconnectAttempts(): number {
return this.maxAttempts;
}
/** Subscribe to data topics */
subscribe(topics: string[]): void {
topics.forEach((t) => this.subscribedTopics.add(t));

View File

@@ -111,10 +111,11 @@ export function BacktestingDashboard() {
</div>
<form onSubmit={handleSubmit} className="p-3 space-y-3">
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">
<label htmlFor="bt-strategy" className="block text-xs text-[var(--color-text-secondary)] mb-1">
Strategy
</label>
<select
id="bt-strategy"
value={strategy}
onChange={(e) => setStrategy(e.target.value)}
className="w-full px-2 py-1.5 text-sm rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none"
@@ -127,10 +128,11 @@ export function BacktestingDashboard() {
</select>
</div>
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">
<label htmlFor="bt-symbol" className="block text-xs text-[var(--color-text-secondary)] mb-1">
Symbol
</label>
<input
id="bt-symbol"
type="text"
value={symbol}
onChange={(e) => {
@@ -144,15 +146,16 @@ export function BacktestingDashboard() {
}`}
/>
{validationErrors.symbol && (
<p className="text-xs text-[var(--color-red)] mt-0.5">{validationErrors.symbol}</p>
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.symbol}</p>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">
<label htmlFor="bt-start-date" className="block text-xs text-[var(--color-text-secondary)] mb-1">
Start Date
</label>
<input
id="bt-start-date"
type="date"
value={startDate}
onChange={(e) => {
@@ -167,10 +170,11 @@ export function BacktestingDashboard() {
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">
<label htmlFor="bt-end-date" className="block text-xs text-[var(--color-text-secondary)] mb-1">
End Date
</label>
<input
id="bt-end-date"
type="date"
value={endDate}
onChange={(e) => {
@@ -186,7 +190,7 @@ export function BacktestingDashboard() {
</div>
</div>
{validationErrors.dateRange && (
<p className="text-xs text-[var(--color-red)] mt-0.5">{validationErrors.dateRange}</p>
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.dateRange}</p>
)}
<button
type="submit"
@@ -201,7 +205,7 @@ export function BacktestingDashboard() {
</p>
)}
{startBacktest.isError && (
<p className="text-xs text-[var(--color-red)]">
<p className="text-xs text-[var(--color-red)]" role="alert">
{startBacktest.error.message}
</p>
)}
@@ -233,6 +237,10 @@ export function BacktestingDashboard() {
<div
className="bg-[var(--color-accent)] h-1.5 rounded-full transition-all"
style={{ width: `${(backtestStatus.data.progress * 100).toFixed(0)}%` }}
role="progressbar"
aria-valuenow={Math.round(backtestStatus.data.progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
)}
@@ -261,12 +269,12 @@ export function BacktestingDashboard() {
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--color-text-secondary)] border-b border-[var(--color-border)]">
<th className="text-left px-3 py-2 font-medium">Time</th>
<th className="text-left px-3 py-2 font-medium">Symbol</th>
<th className="text-left px-3 py-2 font-medium">Side</th>
<th className="text-right px-3 py-2 font-medium">Entry</th>
<th className="text-right px-3 py-2 font-medium">Exit</th>
<th className="text-right px-3 py-2 font-medium">P&L</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Time</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Symbol</th>
<th scope="col" className="text-left px-3 py-2 font-medium">Side</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Entry</th>
<th scope="col" className="text-right px-3 py-2 font-medium">Exit</th>
<th scope="col" className="text-right px-3 py-2 font-medium">P&L</th>
</tr>
</thead>
<tbody>