// App shell: status spine, left rail, notifications slide-over, login.
const { Pill, Card } = window.UI;
const NAV = [
{ id: 'strategy101', label: 'Bot 101', icon: Icon.Hash, hint: '1' },
{ id: 'dashboard', label: 'Dashboard', icon: Icon.Dashboard, hint: 'D' },
{ id: 'pairs', label: 'Pairs', icon: Icon.Grid, hint: 'P' },
{ id: 'trades', label: 'Trades', icon: Icon.Trades, hint: 'T' },
{ id: 'config', label: 'Config', icon: Icon.Config, hint: 'C' },
{ id: 'control', label: 'Control', icon: Icon.Control, hint: 'O' },
{ id: 'logs', label: 'Logs', icon: Icon.Logs, hint: 'L' },
{ id: 'replay', label: 'Replay', icon: Icon.Replay, hint: 'R' },
{ id: 'onboarding', label: 'How it Works', icon: Icon.Info, hint: 'H' },
];
// Left rail — pin/unpin toggle persists to localStorage.
//
// Default behaviour: pinned (rail stays open at w-52). Click the pin
// icon to collapse to icon-only (w-12) with hover-to-expand. The
// preference survives reloads. Operator-friendly: not everyone wants
// the rail eating 200px of dashboard real estate.
function Sidebar({ route, setRoute, app }) {
const [pinned, setPinned] = React.useState(() => {
const v = localStorage.getItem('pb-sidebar-pinned');
return v === null ? true : v === '1';
});
const [hover, setHover] = React.useState(false);
const open = pinned || hover;
const togglePin = () => {
setPinned(p => {
const next = !p;
localStorage.setItem('pb-sidebar-pinned', next ? '1' : '0');
return next;
});
};
return (
);
}
// The "status spine" — narrow strip across the very top.
// Mode color owns a 2px tint band that runs the full width.
function StatusSpine({ app, setApp, openNotif, onLogout }) {
const accounts = app.accounts || [];
const acct = accounts.find(a => a.id === app.account) || accounts[0] || null;
const mode = app.modeOverride || (acct && acct.mode) || '';
const modeBand = { live: 'bg-neg-1', paper: 'bg-amb-1', testnet: 'bg-inf-1' }[mode] || 'bg-line-0';
const [acctOpen, setAcctOpen] = React.useState(false);
const acctRef = React.useRef();
React.useEffect(() => {
const h = (e) => { if (acctRef.current && !acctRef.current.contains(e.target)) setAcctOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, []);
const stale = app.tickAge > 5;
const halted = app.halted;
const unread = app.unreadAlerts;
return (
{/* mode tint band */}
{/* main strip */}
{/* account selector */}
{acctOpen && (
accounts
{accounts.length === 0 && (
no accounts configured
)}
{accounts.map(a => (
))}
)}
{mode && {mode}}
{/* kill-switch — clickable when halted so the operator lands on the
Control page (where Resume lives) with one tap. */}
{halted ? (
) : (
running
)}
{/* last tick — seconds since the most recent BBO frame. null
means we've never seen one (badge hidden); 0 means
"<1s ago" which is actually the most reassuring state. */}
{app.tickAge != null && (
tick{app.tickAge < 1 ? '<1s' : `${app.tickAge}s`}
)}
{/* scout iter rate — backend publishes 1 SSE 'scout' frame per
second carrying iter_per_sec. We hide the badge entirely
until the bot has had a chance to scout (post-boot). */}
{app.scoutRate > 0 && (