her / ui /src /App.jsx
geekwrestler's picture
Deploy Her (Gradio Server / ZeroGPU + bucket + per-client isolation + enrichment)
c367c94 verified
import React, { useEffect, useState } from "react";
import { ClipboardList, Lightbulb, Map, FolderOpen, Sparkles, Bot, Boxes, Plug, Terminal } from "lucide-react";
import { C, FD, FB, FM, fmt, fmtWhen, intentOf, intentIcon, turnSeverity, shortPrompt } from "./theme.js";
import { useAnalysis, useApi, fetchAdvice, fetchOverview } from "./useAnalysis.js";
import { Chip, Stat, BinaryRow } from "./components/Primitives.jsx";
import Legend, { defaultVis } from "./components/Legend.jsx";
import SessionGraph from "./components/SessionGraph.jsx";
import TurnGraph from "./components/TurnGraph.jsx";
import { SessionDetail, TurnDetail, ToolDetail } from "./components/DetailPanel.jsx";
import SessionBrowser from "./components/SessionBrowser.jsx";
import ChatPanel from "./components/ChatPanel.jsx";
import ProjectView from "./components/ProjectView.jsx";
import ProjectsHome from "./components/ProjectsHome.jsx";
import SessionReport from "./components/SessionReport.jsx";
import DisclaimerModal, { needsDisclaimer } from "./components/DisclaimerModal.jsx";
// Session-level entity inventory (Mode A left rail): the skills, sub-agents, and
// MCP servers this ONE session used, each traceable to the turn that launched it —
// the same inventory the project view shows, scoped to the session. Renders nothing
// when the session used none (honest silence). Sub-agents run in their OWN
// transcripts, so this never changes the tool/causality counts in the Legend above.
function SessionEntities({ entities, binaries, onOpen }) {
const bins = binaries || [];
const groups = [
["subAgents", "Sub-agents", Bot, C.blue],
["skills", "Skills", Boxes, C.amber],
["mcpServers", "MCP servers", Plug, C.cyan],
];
const hasEntities = !!entities && groups.some(([k]) => (entities[k] || []).length);
if (!hasEntities && !bins.length) return null;
return (
<>
{/* BINARIES — real tools run via Bash (npx remotion -> remotion, railway, …),
a separate dimension from tool calls. Each row traces to its turn. */}
{bins.length > 0 && (
<>
<div style={{ padding: "12px 10px 2px" }}>
<span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 1, color: C.muted }}>BINARIES RUN</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 13px 3px" }}>
<Terminal size={11} color={C.orange} />
<span style={{ fontFamily: FM, fontSize: 9.5, letterSpacing: 0.5, color: C.orange }}>via Bash</span>
<span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>{bins.length}</span>
</div>
<div style={{ padding: "0 8px 6px" }}>
{bins.map((b) => <BinaryRow key={b.binary} b={b} onOpen={onOpen} />)}
<div style={{ fontFamily: FM, fontSize: 8.5, color: C.muted, padding: "4px 6px 0", lineHeight: 1.45 }}>
extracted from the command (the real binary behind <code>npx</code>/<code>bash</code>) · click to jump to the turn it fired
</div>
</div>
</>
)}
{!hasEntities ? null : (
<>
<div style={{ padding: "12px 10px 2px" }}>
<span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 1, color: C.muted }}>ENTITIES IN THIS SESSION</span>
</div>
<div style={{ padding: "0 8px 12px" }}>
{groups.map(([k, label, Icon, color]) => {
const rows = entities[k] || [];
if (!rows.length) return null;
return (
<div key={k} style={{ marginBottom: 6 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 5px 3px" }}>
<Icon size={11} color={color} />
<span style={{ fontFamily: FM, fontSize: 9.5, letterSpacing: 0.5, color }}>{label}</span>
<span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>{rows.length}</span>
</div>
{rows.map((e) => (
<div key={e.name} className="row lift"
onClick={() => e.turns && e.turns.length && onOpen(e.turns[0])}
title={`used in turn(s) ${(e.turns || []).join(", ")}${e.via ? " · via " + e.via : ""}${e.workflow ? " · workflow " + e.workflow : ""}`}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 8px", borderRadius: 6, cursor: "pointer", borderLeft: `2px solid ${color}` }}>
<span style={{ fontFamily: FM, fontSize: 11.5, color: C.text2, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.name}</span>
<span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>×{e.count}</span>
</div>
))}
</div>
);
})}
<div style={{ fontFamily: FM, fontSize: 8.5, color: C.muted, padding: "4px 6px 0", lineHeight: 1.45 }}>
launched from the cited turn · each runs in its own transcript, so it doesn't change the tool or causality counts above
</div>
</div>
</>
)}
</>
);
}
export default function App() {
// sourcePath: null = no session loaded (welcome) · "__demo__" = the bundled demo
// session (click-to-load, never a default) · else a real ~/.claude session, all
// analyzed live by the API server.
const [sourcePath, setSourcePath] = useState(null);
const { status, data, narrated, error } = useAnalysis(sourcePath);
const api = useApi();
// view: "report" = executive summary (DEFAULT) · "session" = Mode A journey graph
// · "turn" = Mode B drill-in
const [view, setView] = useState("report");
const [ti, setTi] = useState(0); // selected turn index
const [tool, setTool] = useState(null); // selected tool index in Mode B
const [vis, setVis] = useState(defaultVis);
const [atHome, setAtHome] = useState(true); // top-level Projects landing is the default
const [browserOpen, setBrowserOpen] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const [overview, setOverview] = useState(null); // plain-English "what happened"
const [advice, setAdvice] = useState(null); // LLM "what could have been better", session-scoped
const [projectCwd, setProjectCwd] = useState(null); // project scope (many sessions)
const [showDisclaimer, setShowDisclaimer] = useState(needsDisclaimer()); // first-run opt-in
const consentModal = showDisclaimer ? <DisclaimerModal onDone={() => setShowDisclaimer(false)} /> : null;
const hasApiEarly = api?.hasApi;
// The human "what happened" overview shown at the top of Mode A. For the bundled
// fixture we reuse the curated narrator outcome; for any real session we ask the
// local model to write one (grounded in the turns). Narrator prose only — the
// numbers stay deterministic.
useEffect(() => {
setOverview(null);
if (atHome || status !== "ready") return;
let alive = true;
const curated = narrated && (narrated.session_outcome || narrated.summary || narrated.outcome);
const fromCurated = () =>
curated && setOverview({ text: String(curated).replace(/^Generated summary[^\n]*\n+/i, "").trim(), label: "narrator" });
// Prefer the freshly-generated "what happened" (plain English, doesn't dwell on
// tokens) for ANY session; fall back to a curated narrated outcome if the API
// or model is down.
if (hasApiEarly) {
(async () => {
try {
const j = await fetchOverview(sourcePath);
if (!alive) return;
if (j && j.overview) setOverview({ text: j.overview, label: j.model ? j.model.split("/").pop() : "narrator" });
else fromCurated();
} catch {
if (alive) fromCurated();
}
})();
} else {
fromCurated();
}
return () => { alive = false; };
}, [status, sourcePath, narrated, hasApiEarly, atHome]);
// Session-scoped advice ("what could have been better") — the local model writes
// it from the engine's fired signals + this session's objective + the Anthropic
// codebook. Best-effort: the deterministic recommendations render instantly and
// this upgrades them to scoped prose when the model answers.
useEffect(() => {
setAdvice(null);
if (atHome || status !== "ready" || !hasApiEarly) return;
let alive = true;
(async () => {
try {
const j = await fetchAdvice(sourcePath);
if (alive && j && Array.isArray(j.recommendations)) setAdvice(j);
} catch { /* fall back to the deterministic recommendations */ }
})();
return () => { alive = false; };
}, [status, sourcePath, hasApiEarly, atHome]);
const openTurn = (i, toolIdx = null) => { setView("turn"); setTi(i); setTool(toolIdx); };
const pickSession = (path) => {
setAtHome(false);
setSourcePath(path);
setView("report"); setTi(0); setTool(null);
setBrowserOpen(false); setProjectCwd(null);
};
const openProject = (cwd) => { setAtHome(false); setProjectCwd(cwd); setBrowserOpen(false); setChatOpen(false); };
const goHome = () => { setAtHome(true); setProjectCwd(null); setChatOpen(false); setBrowserOpen(false); };
// The bundled demo session — loads ONLY on this explicit click (the "__demo__"
// sentinel the server resolves to fixtures/demo-session.jsonl). Never a default.
const openDemo = () => pickSession("__demo__");
const hasApi = api?.hasApi;
// DEFAULT LANDING — the top-level Projects list. From here: project -> sessions
// (ProjectView) -> a session (the graph). ProjectsHome handles its own
// loading/no-engine states, so we only wait while the API probe is in flight.
if (atHome) {
if (api == null) return <Splash text="connecting to the local engine…" />;
return (
<>
<Style />
{consentModal}
<ProjectsHome onOpenProject={openProject} onOpenSession={pickSession} onDemo={openDemo} />
</>
);
}
// Project scope is session-independent — ProjectView loads its own data from `cwd`.
// The welcome / loading / error screens below key off the loaded SESSION's status, so
// scope them to session view only. Otherwise opening a project before any session is
// analyzed (sourcePath still null → status "welcome") falls through to the welcome
// screen, and only "works" once some session has been loaded. A project click must
// never require a loaded session.
const inProject = !!projectCwd;
if (!inProject && status === "welcome")
return (
<div style={{ fontFamily: FB, color: C.text, height: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, background: "radial-gradient(1200px 500px at 50% -10%, #3b3633 0%, #2b2927 60%)" }}>
<Style />
<img src="/her-logo-light.png" alt="Her" style={{ height: 70 }} />
<div style={{ fontFamily: FM, fontSize: 13, color: C.text2 }}>हेर — a detective for your coding-agent sessions · 100% local</div>
<div style={{ fontFamily: FM, fontSize: 12, color: C.muted, maxWidth: 460, textAlign: "center", lineHeight: 1.6 }}>
No session loaded (traces are never bundled). Pick one of your <code style={{ color: C.text2 }}>~/.claude</code> sessions and Her will analyze it live.
</div>
{api == null ? (
<div style={{ fontFamily: FM, fontSize: 11, color: C.muted }}>connecting to the local engine…</div>
) : hasApiEarly ? (
<div className="lift" onClick={() => setBrowserOpen(true)} style={{ cursor: "pointer", display: "flex", alignItems: "center", gap: 8, background: C.orange, color: "#fff", fontFamily: FD, fontWeight: 600, fontSize: 13.5, borderRadius: 9, padding: "11px 20px" }}>
<FolderOpen size={16} /> Browse your sessions
</div>
) : (
<div style={{ fontFamily: FM, fontSize: 12, color: C.amber, border: `1px solid ${C.amber}`, borderRadius: 8, padding: "10px 14px" }}>
Start the local engine: <b>./her</b> &nbsp;(then refresh)
</div>
)}
{browserOpen && <SessionBrowser current={null} onPick={pickSession} onClose={() => setBrowserOpen(false)} />}
</div>
);
if (!inProject && status === "loading")
return <Splash text={sourcePath ? "analyzing session…" : "loading trace…"} />;
if (!inProject && status === "error")
return (
<Splash bad text={`could not load engine output — ${error}`}>
{hasApi && <button onClick={() => setBrowserOpen(true)} style={btnStyle}>browse sessions</button>}
{sourcePath && <button onClick={goHome} style={btnStyle}>back to home</button>}
{browserOpen && <SessionBrowser current={sourcePath} onPick={pickSession} onClose={() => setBrowserOpen(false)} />}
</Splash>
);
// Session-scope data — null/empty in project scope (ProjectView fetches its own from
// `cwd`). The JSX that dereferences these only renders when !inProject, so guarding
// here keeps a project click from throwing on the still-null session `data`.
const S = inProject ? null : data.session;
const turns = inProject ? [] : data.turns;
const t = turns[Math.min(ti, turns.length - 1)];
// Sub-turns are SIZED BY COST (Anthropic token consumption) — what you pay for —
// not by raw cacheRead. cacheRead stays as a secondary metric on hover.
const maxCost = Math.max(1, ...turns.map((x) => x.tokens.cost ?? 0));
return (
<div style={{ fontFamily: FB, color: C.text, height: "100vh", display: "flex", flexDirection: "column", background: "radial-gradient(1200px 500px at 25% -8%, #3b3633 0%, #2b2927 55%)" }}>
<Style />
{consentModal}
{/* header */}
<div style={{ height: 58, background: C.header, borderBottom: `1px solid ${C.borderSoft}`, boxShadow: `0 1px 0 0 ${C.orange}22`, display: "flex", alignItems: "center", padding: "0 18px", gap: 14, flexShrink: 0 }}>
<div className="lift" onClick={goHome} style={{ display: "flex", alignItems: "center", gap: 9, cursor: "pointer" }} title="Her · हेर — back to all projects">
<img src="/her-logo-light.png" alt="Her" style={{ height: 28, display: "block" }} />
<span style={{ fontFamily: FD, fontWeight: 600, fontSize: 13, color: C.muted, letterSpacing: 1 }}>हेर</span>
</div>
{inProject ? (
<div style={{ fontFamily: FM, fontSize: 11, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<span style={{ color: C.orange }}>project</span> · {projectCwd}
</div>
) : (
<div onClick={() => hasApi && openProject(S.cwd)} title={hasApi ? "open the project view — all sessions in this folder" : undefined} style={{ fontFamily: FM, fontSize: 11, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: hasApi ? "pointer" : "default" }}>
session {S.sessionId ? S.sessionId.slice(0, 8) : "?"} · <span style={{ color: hasApi ? C.text2 : C.muted, borderBottom: hasApi ? `1px dotted ${C.border}` : "none" }}>{S.cwd}</span>{S.startedAt ? <> · <span style={{ color: C.text2 }}>{fmtWhen(S.startedAt)}</span></> : null} · {S.gitBranch} · v{S.version}
</div>
)}
<div style={{ flex: 1 }} />
{hasApi && (
<div className="row lift" onClick={() => setBrowserOpen(true)} style={{ cursor: "pointer", display: "flex", alignItems: "center", gap: 6, fontFamily: FM, fontSize: 11, color: C.text2, border: `1px solid ${C.border}`, borderRadius: 7, padding: "5px 10px" }}>
<FolderOpen size={13} color={C.orange} /> {inProject ? "project" : sourcePath === "__demo__" ? "demo session" : sourcePath ? "live session" : "home"} · browse
</div>
)}
<Chip dot={C.cyan} text="100% LOCAL" />
{!inProject && narrated && <Chip dot={C.amber} text="NARRATED" />}
{!inProject && <Stat label="queries" v={S.turns} />}
{!inProject && <Stat label="tools" v={S.tools} />}
{!inProject && <Stat label="token cost" v={fmt(S.cost ?? S.tokens?.cost ?? 0)} grad />}
{!inProject && <Stat label="cache re-reads" v={fmt(S.tokens.cacheRead)} />}
{!inProject && S.context && <Stat label="peak ctx" v={`${Math.round((S.context.peakPct || 0) * 100)}%`} />}
{!inProject && <Stat label="generated" v={fmt(S.tokens.out)} />}
</div>
{inProject ? (
<ProjectView cwd={projectCwd} llama={api?.llama} onOpenSession={pickSession} onBack={goHome} onBrowse={() => setBrowserOpen(true)} />
) : view === "report" ? (
/* DEFAULT: the executive Session Report (full width). Buttons drill into the
Journey Graph (Mode A) and turn-by-turn (Mode B); chat opens on the right. */
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<SessionReport
session={S} turns={turns} binaries={data.binaries} entities={data.entities}
impact={data.impact} recommendations={data.recommendations} advice={advice}
overview={overview} narrated={narrated}
onOpenTurn={openTurn}
onOpenJourney={() => { setView("session"); setTool(null); }}
onOpenRaw={() => openTurn(0)}
onAskHer={() => setChatOpen((v) => !v)}
chatOpen={chatOpen}
/>
{chatOpen && (
<div style={{ width: 392, background: C.panel, borderLeft: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}>
<ChatPanel sessionPath={sourcePath} llama={api?.llama} onFocusTurn={openTurn} />
</div>
)}
</div>
) : (
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
{/* LEFT: mode switch + legend + query list */}
<div style={{ width: 312, background: C.panel, borderRight: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}>
<div style={{ padding: "10px 10px 4px", display: "flex", flexDirection: "column", gap: 5 }}>
<ModeButton active={view === "report"} icon={ClipboardList} title="Session report" sub="the summary · default" onClick={() => { setView("report"); setTool(null); }} />
<ModeButton active={view === "session"} icon={Map} title="Session graph" sub="the journey · Mode A" onClick={() => { setView("session"); setTool(null); }} />
{hasApi && (
<div style={{ display: "flex", gap: 5 }}>
<ModeButton small icon={FolderOpen} title="Sessions" sub="browse all" onClick={() => setBrowserOpen(true)} />
<ModeButton small active={chatOpen} icon={Sparkles} title="Ask Her" sub="this session" onClick={() => setChatOpen((v) => !v)} />
</div>
)}
</div>
{/* Mode A legend lives here when on the landing graph */}
{view === "session" ? (
<div style={{ overflowY: "auto", flex: 1, minHeight: 0 }}>
<SectionLabel text="LEGEND" />
<Legend turns={turns} vis={vis} setVis={setVis} />
<SessionEntities entities={data.entities} binaries={data.binaries} onOpen={openTurn} />
</div>
) : (
<>
<SectionLabel text="QUERIES" />
<div style={{ overflowY: "auto", flex: 1, padding: "0 8px 12px" }}>
{turns.map((x) => {
const on = view === "turn" && x.i === ti;
const heavy = x.heavy; // top-3 by cost (orange)
const over = x.overBudget && !x.heavy; // over the 500k floor (amber)
const II = intentIcon(intentOf(x));
return (
<div key={x.i} className="row" onClick={() => openTurn(x.i)}
style={{ background: on ? C.card : "transparent", border: `1px solid ${on ? C.border : "transparent"}`, borderLeft: `3px solid ${heavy ? C.orange : over ? C.amber : on ? C.border : "transparent"}`, borderRadius: 7, padding: "8px 10px", marginBottom: 5, cursor: "pointer" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={{ width: 20, height: 20, borderRadius: 5, flexShrink: 0, background: C.elevated, display: "flex", alignItems: "center", justifyContent: "center" }}>
<II size={11} color={heavy ? C.orange : C.text2} />
</div>
<span style={{ fontFamily: FM, fontSize: 10, color: C.muted }}>{String(x.i).padStart(2, "0")}</span>
<span style={{ fontSize: 12, fontWeight: on ? 600 : 500, color: on ? C.text : C.text2, lineHeight: 1.3, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{shortPrompt(x)}</span>
{x.guide && <Lightbulb size={12} color={C.amber} style={{ flexShrink: 0 }} />}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, paddingLeft: 28 }}
title={`cost ${fmt(x.tokens.cost ?? 0)} (Anthropic token consumption) · ${fmt(x.tokens.cacheRead)} cache re-reads (cumulative) · ${fmt(x.tokens.out)} generated${x.ctxPeak ? ` · window peaked ${fmt(x.ctxPeak)}` : ""}`}>
<div style={{ flex: 1, height: 4, background: C.black, borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${Math.max(3, (100 * (x.tokens.cost ?? 0)) / maxCost)}%`, height: "100%", background: heavy ? `linear-gradient(90deg,${C.orange},${C.amber})` : over ? C.amber : C.border }} />
</div>
<span style={{ fontFamily: FM, fontSize: 9.5, color: heavy ? C.orange : over ? C.amber : C.muted }}>{fmt(x.tokens.cost ?? 0)}</span>
</div>
</div>
);
})}
</div>
</>
)}
</div>
{/* CENTER */}
{view === "session" ? (
<SessionGraph session={S} turns={turns} narrated={narrated} overview={overview} recommendations={data.recommendations} advice={advice} vis={vis} onOpen={openTurn} />
) : (
<TurnGraph turn={t} selected={tool} onSelect={setTool} vis={vis} />
)}
{/* RIGHT: chat (when open) else the detail panel */}
<div style={{ width: chatOpen ? 392 : 368, background: C.panel, borderLeft: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}>
{chatOpen ? (
<ChatPanel sessionPath={sourcePath} llama={api?.llama} onFocusTurn={openTurn} />
) : view === "session" ? (
<SessionDetail session={S} />
) : tool === null ? (
<TurnDetail turn={t} narrated={narrated} binaries={data.binaries} />
) : (
<ToolDetail tool={t.tools[tool]} onBack={() => setTool(null)} />
)}
</div>
</div>
)}
{browserOpen && <SessionBrowser current={sourcePath} onPick={pickSession} onOpenProject={openProject} onClose={() => setBrowserOpen(false)} />}
</div>
);
}
function ModeButton({ active, icon: Icon, title, sub, onClick, small }) {
return (
<div className="row lift" onClick={onClick} style={{ flex: small ? 1 : "unset", display: "flex", alignItems: "center", gap: 9, padding: small ? "8px 9px" : "11px 11px", cursor: "pointer", borderRadius: 8, background: active ? `linear-gradient(135deg,${C.orangeMut},transparent)` : "transparent", border: `1px solid ${active ? C.orangeBd : C.borderSoft}` }}>
<div style={{ width: small ? 24 : 30, height: small ? 24 : 30, borderRadius: 7, background: active ? C.orange : C.elevated, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<Icon size={small ? 13 : 16} color={active ? "#fff" : C.text2} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: small ? 12 : 13.5, fontWeight: 600, color: active ? C.text : C.text2 }}>{title}</div>
<div style={{ fontFamily: FM, fontSize: 9.5, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{sub}</div>
</div>
</div>
);
}
function SectionLabel({ text }) {
return (
<div style={{ padding: "8px 12px 7px", borderTop: `1px solid ${C.borderSoft}` }}>
<span style={{ fontFamily: FM, fontSize: 10.5, letterSpacing: 0.8, color: C.text2, fontWeight: 600 }}>{text}</span>
</div>
);
}
const btnStyle = { marginTop: 14, marginInline: 6, background: C.card, color: C.text2, border: `1px solid ${C.border}`, borderRadius: 7, padding: "8px 14px", fontFamily: FM, fontSize: 12, cursor: "pointer" };
function Splash({ text, bad, children }) {
return (
<div style={{ fontFamily: FM, color: bad ? C.red : C.text2, height: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: C.bg, padding: 24, textAlign: "center", fontSize: 13 }}>
<div>{text}</div>
<div style={{ display: "flex", gap: 6 }}>{children}</div>
</div>
);
}
function Style() {
return (
<style>{`
*{scrollbar-width:thin;scrollbar-color:${C.border} transparent}
*::-webkit-scrollbar{width:8px;height:8px}
*::-webkit-scrollbar-thumb{background:${C.border};border-radius:4px}
*::-webkit-scrollbar-track{background:transparent}
.row{transition:background .12s ease,border-color .12s ease}
.row:hover{background:${C.card}}
.lift{transition:transform .12s ease,box-shadow .12s ease}
.lift:hover{transform:translateY(-1px)}
.pop{animation:pop .32s ease both}
@keyframes pop{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.glow{animation:glow 2.6s ease-in-out infinite}
@keyframes glow{0%,100%{box-shadow:0 0 0 0 rgba(240,106,23,.0),0 0 14px 0 rgba(240,106,23,.35)}50%{box-shadow:0 0 0 4px rgba(240,106,23,.06),0 0 22px 2px rgba(240,106,23,.5)}}
.spin{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.hb{background:linear-gradient(90deg,${C.orange},${C.amber});-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
`}</style>
);
}