fix(boltbot): address web interface guidelines review
- color-scheme: dark on html, theme-color meta, touch-action: manipulation - prefers-reduced-motion: disable animations/transitions globally - Intl.RelativeTimeFormat + Intl.NumberFormat replace hardcoded formats - URL state sync: viewMode, selectedTiers, anomalyOnly persist via search params (replaceState) - Skip-to-content link for keyboard navigation - tabular-nums on all numeric displays (stats, duration, counts) - min-w-0 on truncated flex/grid children - overscroll-behavior: contain on dialog panel - aria-live on loading skeleton - "Loading..." → "Loading…" (proper ellipsis)
This commit is contained in:
parent
146a55836c
commit
b2c6b055ee
@ -1,8 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" style="color-scheme: dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
<title>Boltbot Dashboard</title>
|
<title>Boltbot Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@ -12,12 +12,32 @@ import SessionView from "./components/SessionView";
|
|||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
function getSearchParams() {
|
||||||
|
return new URLSearchParams(window.location.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSearchParams(params: Record<string, string>) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v) url.searchParams.set(k, v);
|
||||||
|
else url.searchParams.delete(k);
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [selectedTiers, setSelectedTiers] = useState<string[]>([]);
|
const initialParams = getSearchParams();
|
||||||
const [anomalyOnly, setAnomalyOnly] = useState(false);
|
const [selectedTiers, setSelectedTiers] = useState<string[]>(() => {
|
||||||
|
const tiers = initialParams.get("tiers");
|
||||||
|
return tiers ? tiers.split(",").filter(Boolean) : [];
|
||||||
|
});
|
||||||
|
const [anomalyOnly, setAnomalyOnly] = useState(() => initialParams.get("anomalies") === "1");
|
||||||
const [allReceipts, setAllReceipts] = useState<ActionReceipt[]>([]);
|
const [allReceipts, setAllReceipts] = useState<ActionReceipt[]>([]);
|
||||||
const [selectedReceipt, setSelectedReceipt] = useState<ActionReceipt | null>(null);
|
const [selectedReceipt, setSelectedReceipt] = useState<ActionReceipt | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<"list" | "sessions">("list");
|
const [viewMode, setViewMode] = useState<"list" | "sessions">(() => {
|
||||||
|
const view = initialParams.get("view");
|
||||||
|
return view === "sessions" ? "sessions" : "list";
|
||||||
|
});
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [loadedCount, setLoadedCount] = useState(0);
|
const [loadedCount, setLoadedCount] = useState(0);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
@ -27,6 +47,19 @@ export default function App() {
|
|||||||
const { stats, isLoading: statsLoading, error: statsError } = useStats();
|
const { stats, isLoading: statsLoading, error: statsError } = useStats();
|
||||||
const { receipts, isLoading: receiptsLoading, error: receiptsError } = useReceipts(LIMIT, 0);
|
const { receipts, isLoading: receiptsLoading, error: receiptsError } = useReceipts(LIMIT, 0);
|
||||||
|
|
||||||
|
// Sync URL with state changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParams({ view: viewMode === "list" ? "" : viewMode });
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParams({ tiers: selectedTiers.join(",") });
|
||||||
|
}, [selectedTiers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParams({ anomalies: anomalyOnly ? "1" : "" });
|
||||||
|
}, [anomalyOnly]);
|
||||||
|
|
||||||
// Polling: merge new receipts at the top, do NOT touch loadedCount or hasMore
|
// Polling: merge new receipts at the top, do NOT touch loadedCount or hasMore
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (receipts) {
|
if (receipts) {
|
||||||
@ -82,9 +115,12 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-950 text-neutral-100">
|
<div className="min-h-screen bg-neutral-950 text-neutral-100">
|
||||||
|
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:bg-neutral-800 focus:text-white focus:px-4 focus:py-2 focus:rounded-lg">
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<Header />
|
<Header />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="pt-14 pl-0 lg:pl-56 p-6">
|
<main id="main-content" className="pt-14 pl-0 lg:pl-56 p-6">
|
||||||
<div className="max-w-6xl mx-auto space-y-6 pt-6">
|
<div className="max-w-6xl mx-auto space-y-6 pt-6">
|
||||||
<StatsCards stats={stats} isLoading={statsLoading} error={statsError} />
|
<StatsCards stats={stats} isLoading={statsLoading} error={statsError} />
|
||||||
|
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export default function ReceiptDetail({ receipt, onClose }: Props) {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="receipt-detail-title"
|
aria-labelledby="receipt-detail-title"
|
||||||
className="fixed inset-y-0 right-0 w-[420px] max-w-full bg-neutral-900 border-l border-neutral-800 z-50 overflow-y-auto"
|
className="fixed inset-y-0 right-0 w-[420px] max-w-full bg-neutral-900 border-l border-neutral-800 z-50 overflow-y-auto [overscroll-behavior:contain]"
|
||||||
>
|
>
|
||||||
<div key={receipt.id} className="p-5">
|
<div key={receipt.id} className="p-5">
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
@ -167,7 +167,7 @@ export default function ReceiptDetail({ receipt, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-neutral-400">Duration</div>
|
<div className="text-xs text-neutral-400">Duration</div>
|
||||||
<div className="text-sm">{formatDuration(receipt.durationMs)}</div>
|
<div className="text-sm" style={{ fontVariantNumeric: "tabular-nums" }}>{formatDuration(receipt.durationMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-neutral-400">Session</div>
|
<div className="text-xs text-neutral-400">Session</div>
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export default function ReceiptList({
|
|||||||
|
|
||||||
if (isLoading && receipts.length === 0) {
|
if (isLoading && receipts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2" aria-live="polite">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="h-10 animate-pulse bg-neutral-800 rounded-lg" />
|
<div key={i} className="h-10 animate-pulse bg-neutral-800 rounded-lg" />
|
||||||
))}
|
))}
|
||||||
@ -85,7 +85,7 @@ export default function ReceiptList({
|
|||||||
: "hover:bg-neutral-800/30 border-l-2 border-transparent",
|
: "hover:bg-neutral-800/30 border-l-2 border-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span role="cell" className="text-sm font-mono truncate">{r.toolName}</span>
|
<span role="cell" className="text-sm font-mono truncate min-w-0">{r.toolName}</span>
|
||||||
<span role="cell">
|
<span role="cell">
|
||||||
<span className={cn("rounded-full px-2 py-0.5 text-xs", tierBadge[r.tier])}>
|
<span className={cn("rounded-full px-2 py-0.5 text-xs", tierBadge[r.tier])}>
|
||||||
{r.tier}
|
{r.tier}
|
||||||
@ -119,7 +119,7 @@ export default function ReceiptList({
|
|||||||
loadingMore ? "opacity-50 cursor-not-allowed" : "hover:bg-neutral-700",
|
loadingMore ? "opacity-50 cursor-not-allowed" : "hover:bg-neutral-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{loadingMore ? "Loading..." : "Load More"}
|
{loadingMore ? "Loading…" : "Load More"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -77,10 +77,10 @@ function SessionCard({
|
|||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-xs truncate flex-1 text-neutral-300">
|
<span className="font-mono text-xs truncate flex-1 min-w-0 text-neutral-300">
|
||||||
{group.sessionKey}
|
{group.sessionKey}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">
|
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{group.receipts.length}
|
{group.receipts.length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-400">
|
<span className="text-xs text-neutral-400">
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export default function StatsCards({ stats, isLoading, error }: Props) {
|
|||||||
{isLoading && !stats ? (
|
{isLoading && !stats ? (
|
||||||
<div className="h-8 w-16 animate-pulse bg-neutral-800 rounded" />
|
<div className="h-8 w-16 animate-pulse bg-neutral-800 rounded" />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-2xl font-bold">{getValue(stats, card.key)}</div>
|
<div className="text-2xl font-bold" style={{ fontVariantNumeric: "tabular-nums" }}>{getValue(stats, card.key)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -5,4 +5,14 @@ body {
|
|||||||
color: #f5f5f5; /* neutral-100 */
|
color: #f5f5f5; /* neutral-100 */
|
||||||
font-family: "Space Grotesk", sans-serif;
|
font-family: "Space Grotesk", sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,27 +8,31 @@ export function formatRelativeTime(iso: string): string {
|
|||||||
const seconds = Math.floor(diffMs / 1000);
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
if (seconds < 60) return "just now";
|
if (seconds < 60) return "just now";
|
||||||
|
|
||||||
|
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return rtf.format(-minutes, "minute");
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return rtf.format(-hours, "hour");
|
||||||
|
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
if (days < 30) return `${days}d ago`;
|
if (days < 30) return rtf.format(-days, "day");
|
||||||
|
|
||||||
const months = Math.floor(days / 30);
|
const months = Math.floor(days / 30);
|
||||||
if (months < 12) return `${months}mo ago`;
|
if (months < 12) return rtf.format(-months, "month");
|
||||||
|
|
||||||
const years = Math.floor(months / 12);
|
const years = Math.floor(months / 12);
|
||||||
return `${years}y ago`;
|
return rtf.format(-years, "year");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
|
const nf = new Intl.NumberFormat("en", { maximumFractionDigits: 1 });
|
||||||
if (ms >= 1000) {
|
if (ms >= 1000) {
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
return `${nf.format(ms / 1000)}s`;
|
||||||
}
|
}
|
||||||
return `${ms}ms`;
|
const intFormatter = new Intl.NumberFormat("en", { maximumFractionDigits: 0 });
|
||||||
|
return `${intFormatter.format(ms)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cn(...classes: (string | false | undefined | null)[]): string {
|
export function cn(...classes: (string | false | undefined | null)[]): string {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user