feat: light/dark theme + app shell (sidebar, topbar, auth, login page)
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
import { createResource, createSignal, Show } from "solid-js";
|
||||||
|
import { api, type AuthUser } from "../lib/api";
|
||||||
|
|
||||||
|
// AuthState: loads /api/auth/me once; exposes login/logout + theme toggle.
|
||||||
|
function theme(): string {
|
||||||
|
return localStorage.getItem("fs-theme") ||
|
||||||
|
(matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(t: string) {
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
localStorage.setItem("fs-theme", t);
|
||||||
|
}
|
||||||
|
applyTheme(theme());
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [t, setT] = createSignal(theme());
|
||||||
|
return (
|
||||||
|
<button class="fs-iconbtn" title="Toggle light/dark" onClick={() => {
|
||||||
|
const n = t() === "dark" ? "light" : "dark";
|
||||||
|
setT(n); applyTheme(n);
|
||||||
|
}}>
|
||||||
|
{t() === "dark" ? "☀️" : "🌙"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
||||||
|
try { return (await api.me()).user; } catch { return null; }
|
||||||
|
});
|
||||||
|
return { me, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
||||||
|
return (
|
||||||
|
<Show when={props.me} fallback={
|
||||||
|
<a class="fs-btn" href="/api/auth/start">Sign in with Google</a>
|
||||||
|
}>
|
||||||
|
{(u) => (
|
||||||
|
<span class="fs-user">
|
||||||
|
{u().avatar_url ? <img class="fs-avatar" src={u().avatar_url} alt="" /> : null}
|
||||||
|
{u().name || u().email}
|
||||||
|
<button class="fs-iconbtn" onClick={props.onLogout}>Logout</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { For, Show, type JSX } from "solid-js";
|
||||||
|
|
||||||
|
// Shared building blocks: StatCard, EmptyState, Badge, SignalBar, Skeleton.
|
||||||
|
|
||||||
|
export function StatCard(props: { title: string; value: string; sub?: string; children?: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="fs-card">
|
||||||
|
<h3>{props.title}</h3>
|
||||||
|
<p class="fs-stat">{props.value}</p>
|
||||||
|
<Show when={props.sub}><p class="fs-muted" style={{ margin: 0 }}>{props.sub}</p></Show>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState(props: { icon: string; title: string; hint?: string; action?: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="fs-card fs-empty">
|
||||||
|
<div class="big">{props.icon}</div>
|
||||||
|
<strong>{props.title}</strong>
|
||||||
|
<Show when={props.hint}><p class="fs-muted">{props.hint}</p></Show>
|
||||||
|
<Show when={props.action}><div style={{ "margin-top": "10px" }}>{props.action}</div></Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton(props: { rows?: number }) {
|
||||||
|
const n = props.rows ?? 3;
|
||||||
|
return (
|
||||||
|
<div class="fs-stack">
|
||||||
|
<For each={Array.from({ length: n })}>{() => <div class="fs-sk" style={{ height: "56px" }} />}</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecBadge(props: { rec: string }) {
|
||||||
|
const cls = props.rec === "BUY" ? "buy" : props.rec === "HOLD" ? "hold" : "avoid";
|
||||||
|
return <span class={`fs-badge ${cls}`}>{props.rec}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalBar renders one -100..+100 signal as a labeled bar.
|
||||||
|
export function SignalBar(props: { label: string; value: number }) {
|
||||||
|
const v = Math.max(-100, Math.min(100, props.value));
|
||||||
|
const left = v < 0 ? `${50 + v / 2}%` : "50%";
|
||||||
|
const width = `${Math.abs(v) / 2}%`;
|
||||||
|
const cls = v < 0 ? "neg" : v > 0 ? "pos" : "";
|
||||||
|
return (
|
||||||
|
<div class="fs-sig">
|
||||||
|
<span class="fs-muted">{props.label}</span>
|
||||||
|
<span class="bar"><i class={cls} style={{ left, width }} /></span>
|
||||||
|
<b>{v}</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreakdownBars renders a screen/report signal breakdown map as bars.
|
||||||
|
// Unknown numeric fields are shown; booleans/strings are skipped except flags.
|
||||||
|
export function BreakdownBars(props: { breakdown: Record<string, unknown> }) {
|
||||||
|
const entries = () =>
|
||||||
|
Object.entries(props.breakdown || {}).filter(([, v]) => typeof v === "number") as [string, number][];
|
||||||
|
return (
|
||||||
|
<Show when={entries().length} fallback={<span class="fs-muted">no signals</span>}>
|
||||||
|
<For each={entries()}>{([k, v]) => <SignalBar label={k.replace(/_/g, " ")} value={v} />}</For>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
+74
-20
@@ -1,6 +1,9 @@
|
|||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import { Router, Route, useLocation } from "@solidjs/router";
|
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||||
|
import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
|
||||||
|
import { api } from "./lib/api";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Routines from "./pages/Routines";
|
import Routines from "./pages/Routines";
|
||||||
import Screener from "./pages/Screener";
|
import Screener from "./pages/Screener";
|
||||||
@@ -11,39 +14,90 @@ import "./styles/tokens.css";
|
|||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const link = (href: string, label: string) => (
|
const link = (href: string, icon: string, label: string) => (
|
||||||
<a href={href} class={loc.pathname === href ? "active" : ""}>{label}</a>
|
<a href={href} class={loc.pathname === href ? "active" : ""}><span>{icon}</span> {label}</a>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<nav class="fs-nav">
|
<nav class="fs-nav" id="fs-nav">
|
||||||
<strong>FlowSight</strong>
|
<div class="fs-brand">Flow<span>Sight</span></div>
|
||||||
{link("/", "Dashboard")}
|
{link("/", "📊", "Dashboard")}
|
||||||
{link("/routines", "Routines")}
|
{link("/routines", "🗓️", "Routines")}
|
||||||
{link("/screener", "Screener")}
|
{link("/screener", "🔍", "Screener")}
|
||||||
{link("/alerts", "Alerts")}
|
{link("/alerts", "🔔", "Alerts")}
|
||||||
{link("/portfolio", "Portfolio")}
|
{link("/portfolio", "💼", "Portfolio")}
|
||||||
|
<div class="fs-nav-foot">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TickerSearch() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [q, setQ] = createSignal("");
|
||||||
|
const go = () => {
|
||||||
|
const t = q().toUpperCase().trim();
|
||||||
|
if (t) nav(`/report/${t}`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form class="fs-search" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||||
|
<input class="fs-input" placeholder="Ticker → report (e.g. BBCA)" value={q()}
|
||||||
|
onInput={(e) => setQ(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||||
|
<button class="fs-btn" type="submit">Open</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const params = useParams<{ error?: string }>();
|
||||||
|
const err = () => new URLSearchParams(window.location.search).get("error");
|
||||||
|
void params;
|
||||||
|
return (
|
||||||
|
<div style={{ "max-width": "440px", margin: "8vh auto" }}>
|
||||||
|
<div class="fs-card" style={{ padding: "28px", "text-align": "center" }}>
|
||||||
|
<h1 style={{ margin: "0 0 8px" }}>Welcome to FlowSight</h1>
|
||||||
|
<p class="fs-muted">Sign in with Google to get your own watchlist, routines, alerts and reports.</p>
|
||||||
|
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>Login failed: {err()}</p></Show>
|
||||||
|
<a class="fs-btn" href="/api/auth/start" style={{ display: "inline-block", "margin-top": "12px" }}>Sign in with Google</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell(props: RouteSectionProps) {
|
||||||
|
const { me, refetch } = useAuth();
|
||||||
|
const logout = async () => { await api.logout(); refetch(); };
|
||||||
|
return (
|
||||||
|
<div class="fs-app">
|
||||||
|
<Nav />
|
||||||
|
<div class="fs-body">
|
||||||
|
<header class="fs-topbar">
|
||||||
|
<button id="fs-menu-btn" class="fs-iconbtn" onClick={() => document.getElementById("fs-nav")?.classList.toggle("open")}>☰</button>
|
||||||
|
<TickerSearch />
|
||||||
|
<span class="fs-spacer" />
|
||||||
|
<ThemeToggle />
|
||||||
|
<AuthButton me={me()} onLogout={logout} />
|
||||||
|
</header>
|
||||||
|
<main class="fs-main">{props.children}</main>
|
||||||
|
</div>
|
||||||
|
<aside class="fs-side">
|
||||||
|
<WatchlistDrawer />
|
||||||
|
<ChatSidebar />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<Router root={(props) => (
|
<Router root={Shell}>
|
||||||
<div class="fs-app">
|
|
||||||
<Nav />
|
|
||||||
<main class="fs-main">{props.children}</main>
|
|
||||||
<aside style={{ width: "300px", padding: "20px 16px 20px 0", display: "flex", "flex-direction": "column", gap: "12px" }}>
|
|
||||||
<WatchlistDrawer />
|
|
||||||
<ChatSidebar />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
)}>
|
|
||||||
<Route path="/" component={Dashboard} />
|
<Route path="/" component={Dashboard} />
|
||||||
<Route path="/routines" component={Routines} />
|
<Route path="/routines" component={Routines} />
|
||||||
<Route path="/screener" component={Screener} />
|
<Route path="/screener" component={Screener} />
|
||||||
<Route path="/alerts" component={Alerts} />
|
<Route path="/alerts" component={Alerts} />
|
||||||
<Route path="/report/:ticker" component={Report} />
|
<Route path="/report/:ticker" component={Report} />
|
||||||
<Route path="/portfolio" component={Portfolio} />
|
<Route path="/portfolio" component={Portfolio} />
|
||||||
|
<Route path="/login" component={LoginPage} />
|
||||||
</Router>
|
</Router>
|
||||||
),
|
),
|
||||||
document.getElementById("root")!,
|
document.getElementById("root")!,
|
||||||
|
|||||||
+4
-1
@@ -22,6 +22,7 @@ export interface ScreenRow { symbol: string; name: string; composite: number; br
|
|||||||
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
|
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
|
||||||
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
|
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
|
||||||
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[] }
|
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[] }
|
||||||
|
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
|
||||||
export const api = {
|
export const api = {
|
||||||
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
||||||
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
||||||
@@ -49,7 +50,9 @@ export const api = {
|
|||||||
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
|
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
|
||||||
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
|
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
|
||||||
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
|
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
|
||||||
chat: (message: string) => req<{ answer: string }>("/api/chat", { method: "POST", body: JSON.stringify({ message }) }),
|
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
||||||
|
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
|
||||||
|
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
|
||||||
};
|
};
|
||||||
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
||||||
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
||||||
|
|||||||
+116
-19
@@ -1,38 +1,135 @@
|
|||||||
/* FlowSight design tokens: single source of truth for color, type, spacing. */
|
/* FlowSight design tokens v2: light default, dark via [data-theme="dark"].
|
||||||
|
Single source of truth for color, type, spacing. */
|
||||||
:root {
|
:root {
|
||||||
|
--fs-bg: #f6f8fb;
|
||||||
|
--fs-surface: #ffffff;
|
||||||
|
--fs-surface-2: #eef2f7;
|
||||||
|
--fs-border: #dfe6ef;
|
||||||
|
--fs-text: #0f1c2e;
|
||||||
|
--fs-muted: #5b6b82;
|
||||||
|
--fs-accent: #1d6ff2;
|
||||||
|
--fs-accent-soft: #e3edff;
|
||||||
|
--fs-buy: #15803d;
|
||||||
|
--fs-buy-soft: #dcfce7;
|
||||||
|
--fs-hold: #b45309;
|
||||||
|
--fs-hold-soft: #fef3c7;
|
||||||
|
--fs-avoid: #dc2626;
|
||||||
|
--fs-avoid-soft: #fee2e2;
|
||||||
|
--fs-stale: #7c3aed;
|
||||||
|
--fs-font: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
--fs-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--fs-radius: 10px;
|
||||||
|
--fs-gap: 14px;
|
||||||
|
--fs-shadow: 0 1px 2px rgba(15, 28, 46, 0.06), 0 4px 16px rgba(15, 28, 46, 0.06);
|
||||||
|
--fs-nav-w: 216px;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
--fs-bg: #0d1117;
|
--fs-bg: #0d1117;
|
||||||
--fs-surface: #161b22;
|
--fs-surface: #161b22;
|
||||||
|
--fs-surface-2: #1c2330;
|
||||||
--fs-border: #30363d;
|
--fs-border: #30363d;
|
||||||
--fs-text: #e6edf3;
|
--fs-text: #e6edf3;
|
||||||
--fs-muted: #8b949e;
|
--fs-muted: #8b949e;
|
||||||
--fs-accent: #2f81f7;
|
--fs-accent: #2f81f7;
|
||||||
|
--fs-accent-soft: #12233f;
|
||||||
--fs-buy: #3fb950;
|
--fs-buy: #3fb950;
|
||||||
|
--fs-buy-soft: #12291a;
|
||||||
--fs-hold: #d29922;
|
--fs-hold: #d29922;
|
||||||
|
--fs-hold-soft: #2e230d;
|
||||||
--fs-avoid: #f85149;
|
--fs-avoid: #f85149;
|
||||||
|
--fs-avoid-soft: #341314;
|
||||||
--fs-stale: #9e7bea;
|
--fs-stale: #9e7bea;
|
||||||
--fs-font: "Inter", system-ui, sans-serif;
|
--fs-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
--fs-mono: "JetBrains Mono", ui-monospace, monospace;
|
|
||||||
--fs-radius: 8px;
|
|
||||||
--fs-gap: 12px;
|
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: var(--fs-bg); color: var(--fs-text); font-family: var(--fs-font); }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
a { color: var(--fs-accent); }
|
body { margin: 0; background: var(--fs-bg); color: var(--fs-text); font-family: var(--fs-font); font-size: 15px; line-height: 1.5; }
|
||||||
|
a { color: var(--fs-accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
.fs-app { display: flex; min-height: 100vh; }
|
.fs-app { display: flex; min-height: 100vh; }
|
||||||
.fs-nav { width: 200px; padding: 16px; border-right: 1px solid var(--fs-border); display: flex; flex-direction: column; gap: 8px; }
|
.fs-nav { width: var(--fs-nav-w); flex-shrink: 0; padding: 16px 12px; border-right: 1px solid var(--fs-border); background: var(--fs-surface); display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh; }
|
||||||
.fs-nav a { padding: 8px 12px; border-radius: var(--fs-radius); text-decoration: none; color: var(--fs-text); }
|
.fs-brand { font-weight: 800; font-size: 18px; padding: 4px 10px 12px; letter-spacing: -0.01em; }
|
||||||
.fs-nav a.active { background: var(--fs-surface); }
|
.fs-brand span { color: var(--fs-accent); }
|
||||||
.fs-main { flex: 1; padding: 20px; max-width: 1200px; }
|
.fs-nav a { padding: 9px 12px; border-radius: var(--fs-radius); color: var(--fs-text); font-weight: 500; display: flex; gap: 10px; align-items: center; }
|
||||||
.fs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--fs-gap); }
|
.fs-nav a:hover { background: var(--fs-surface-2); text-decoration: none; }
|
||||||
.fs-card { background: var(--fs-surface); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 14px; }
|
.fs-nav a.active { background: var(--fs-accent-soft); color: var(--fs-accent); font-weight: 700; }
|
||||||
|
.fs-nav .fs-nav-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.fs-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.fs-topbar { display: flex; gap: 10px; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--fs-border); background: var(--fs-surface); position: sticky; top: 0; z-index: 10; }
|
||||||
|
.fs-topbar .fs-search { flex: 1; max-width: 420px; display: flex; gap: 8px; }
|
||||||
|
.fs-topbar .fs-spacer { flex: 1; }
|
||||||
|
.fs-main { flex: 1; padding: 22px; max-width: 1280px; width: 100%; margin: 0 auto; }
|
||||||
|
.fs-side { width: 320px; flex-shrink: 0; padding: 22px 18px 22px 0; display: flex; flex-direction: column; gap: var(--fs-gap); }
|
||||||
|
.fs-page-head { display: flex; align-items: baseline; gap: 12px; margin: 2px 0 16px; flex-wrap: wrap; }
|
||||||
|
.fs-page-head h1 { font-size: 24px; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.fs-page-head p { margin: 0; color: var(--fs-muted); }
|
||||||
|
|
||||||
|
/* Cards, grid, stats */
|
||||||
|
.fs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--fs-gap); }
|
||||||
|
.fs-card { background: var(--fs-surface); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 16px; box-shadow: var(--fs-shadow); }
|
||||||
|
.fs-card h3 { margin: 0 0 10px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fs-muted); }
|
||||||
|
.fs-stat { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; margin: 2px 0; font-variant-numeric: tabular-nums; }
|
||||||
.fs-muted { color: var(--fs-muted); font-size: 13px; }
|
.fs-muted { color: var(--fs-muted); font-size: 13px; }
|
||||||
.fs-cite { display: inline-block; font-family: var(--fs-mono); font-size: 11px; border: 1px solid var(--fs-border); border-radius: 4px; padding: 1px 6px; margin: 2px 4px 2px 0; }
|
.fs-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.fs-stack { display: flex; flex-direction: column; gap: var(--fs-gap); }
|
||||||
|
|
||||||
|
/* Badges, signals */
|
||||||
|
.fs-badge { display: inline-block; font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 999px; }
|
||||||
|
.fs-badge.buy { background: var(--fs-buy-soft); color: var(--fs-buy); }
|
||||||
|
.fs-badge.hold { background: var(--fs-hold-soft); color: var(--fs-hold); }
|
||||||
|
.fs-badge.avoid { background: var(--fs-avoid-soft); color: var(--fs-avoid); }
|
||||||
|
.fs-badge.info { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||||
|
.fs-sig { display: grid; grid-template-columns: 110px 1fr 52px; gap: 8px; align-items: center; margin: 6px 0; font-size: 13px; }
|
||||||
|
.fs-sig .bar { height: 8px; border-radius: 99px; background: var(--fs-surface-2); position: relative; overflow: hidden; }
|
||||||
|
.fs-sig .bar i { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: var(--fs-accent); }
|
||||||
|
.fs-sig .bar i.neg { background: var(--fs-avoid); }
|
||||||
|
.fs-sig .bar i.pos { background: var(--fs-buy); }
|
||||||
|
.fs-sig b { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Citations */
|
||||||
|
.fs-cite { display: inline-block; font-family: var(--fs-mono); font-size: 11px; border: 1px solid var(--fs-border); border-radius: 6px; padding: 1px 7px; margin: 2px 4px 2px 0; background: var(--fs-surface-2); }
|
||||||
.fs-cite.stale { border-color: var(--fs-stale); color: var(--fs-stale); }
|
.fs-cite.stale { border-color: var(--fs-stale); color: var(--fs-stale); }
|
||||||
|
|
||||||
|
/* Tables, forms */
|
||||||
.fs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
.fs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
.fs-table th, .fs-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--fs-border); }
|
.fs-table th, .fs-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--fs-border); }
|
||||||
.fs-input, .fs-btn { font: inherit; padding: 8px 12px; border-radius: var(--fs-radius); border: 1px solid var(--fs-border); }
|
.fs-table th { color: var(--fs-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.fs-input { background: var(--fs-bg); color: var(--fs-text); }
|
.fs-table tr:hover td { background: var(--fs-surface-2); }
|
||||||
.fs-btn { background: var(--fs-accent); color: #fff; cursor: pointer; border: none; }
|
.fs-input, .fs-btn, select.fs-input { font: inherit; padding: 9px 13px; border-radius: var(--fs-radius); border: 1px solid var(--fs-border); }
|
||||||
.fs-btn.ghost { background: transparent; border: 1px solid var(--fs-border); color: var(--fs-text); }
|
.fs-input { background: var(--fs-surface); color: var(--fs-text); min-width: 0; }
|
||||||
|
.fs-input:focus { outline: 2px solid var(--fs-accent); outline-offset: -1px; border-color: var(--fs-accent); }
|
||||||
|
.fs-btn { background: var(--fs-accent); color: #fff; cursor: pointer; border: 1px solid transparent; font-weight: 600; }
|
||||||
|
.fs-btn:hover { filter: brightness(1.07); }
|
||||||
|
.fs-btn.ghost { background: transparent; border-color: var(--fs-border); color: var(--fs-text); }
|
||||||
|
.fs-btn.danger { background: transparent; border-color: var(--fs-avoid); color: var(--fs-avoid); }
|
||||||
|
.fs-btn:disabled { opacity: 0.55; cursor: wait; }
|
||||||
.fs-scroll { overflow-x: auto; }
|
.fs-scroll { overflow-x: auto; }
|
||||||
|
.fs-avatar { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
|
||||||
|
|
||||||
|
/* Skeletons, empty */
|
||||||
|
.fs-sk { border-radius: 8px; background: linear-gradient(90deg, var(--fs-surface-2) 25%, var(--fs-border) 50%, var(--fs-surface-2) 75%); background-size: 200% 100%; animation: fs-sh 1.2s infinite; min-height: 18px; }
|
||||||
|
@keyframes fs-sh { to { background-position: -200% 0; } }
|
||||||
|
.fs-empty { text-align: center; padding: 34px 16px; color: var(--fs-muted); }
|
||||||
|
.fs-empty .big { font-size: 30px; margin-bottom: 6px; }
|
||||||
|
|
||||||
.rec-BUY { color: var(--fs-buy); } .rec-HOLD { color: var(--fs-hold); } .rec-AVOID { color: var(--fs-avoid); }
|
.rec-BUY { color: var(--fs-buy); } .rec-HOLD { color: var(--fs-hold); } .rec-AVOID { color: var(--fs-avoid); }
|
||||||
|
|
||||||
|
/* Theme toggle + user chip */
|
||||||
|
.fs-iconbtn { background: transparent; border: 1px solid var(--fs-border); color: var(--fs-text); border-radius: var(--fs-radius); padding: 8px 11px; cursor: pointer; font: inherit; }
|
||||||
|
.fs-iconbtn:hover { background: var(--fs-surface-2); }
|
||||||
|
.fs-user { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.fs-side { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.fs-nav { position: fixed; z-index: 20; transform: translateX(-100%); transition: transform 0.2s; box-shadow: var(--fs-shadow); }
|
||||||
|
.fs-nav.open { transform: none; }
|
||||||
|
.fs-main { padding: 14px; }
|
||||||
|
.fs-topbar { padding: 10px 12px; }
|
||||||
|
#fs-menu-btn { display: inline-block !important; }
|
||||||
|
}
|
||||||
|
#fs-menu-btn { display: none; }
|
||||||
|
|||||||
Reference in New Issue
Block a user