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>
|
||||
<html lang="en">
|
||||
<html lang="en" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<title>Boltbot Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@ -12,12 +12,32 @@ import SessionView from "./components/SessionView";
|
||||
|
||||
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() {
|
||||
const [selectedTiers, setSelectedTiers] = useState<string[]>([]);
|
||||
const [anomalyOnly, setAnomalyOnly] = useState(false);
|
||||
const initialParams = getSearchParams();
|
||||
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 [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 [loadedCount, setLoadedCount] = useState(0);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
@ -27,6 +47,19 @@ export default function App() {
|
||||
const { stats, isLoading: statsLoading, error: statsError } = useStats();
|
||||
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
|
||||
useEffect(() => {
|
||||
if (receipts) {
|
||||
@ -82,9 +115,12 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<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 />
|
||||
<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">
|
||||
<StatsCards stats={stats} isLoading={statsLoading} error={statsError} />
|
||||
|
||||
|
||||
@ -124,7 +124,7 @@ export default function ReceiptDetail({ receipt, onClose }: Props) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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 className="flex items-center justify-between mb-5">
|
||||
@ -167,7 +167,7 @@ export default function ReceiptDetail({ receipt, onClose }: Props) {
|
||||
</div>
|
||||
<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 className="text-xs text-neutral-400">Session</div>
|
||||
|
||||
@ -39,7 +39,7 @@ export default function ReceiptList({
|
||||
|
||||
if (isLoading && receipts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2" aria-live="polite">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<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 className={cn("rounded-full px-2 py-0.5 text-xs", tierBadge[r.tier])}>
|
||||
{r.tier}
|
||||
@ -119,7 +119,7 @@ export default function ReceiptList({
|
||||
loadingMore ? "opacity-50 cursor-not-allowed" : "hover:bg-neutral-700",
|
||||
)}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
{loadingMore ? "Loading…" : "Load More"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -77,10 +77,10 @@ function SessionCard({
|
||||
)}
|
||||
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}
|
||||
</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}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400">
|
||||
|
||||
@ -45,7 +45,7 @@ export default function StatsCards({ stats, isLoading, error }: Props) {
|
||||
{isLoading && !stats ? (
|
||||
<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>
|
||||
))}
|
||||
|
||||
@ -5,4 +5,14 @@ body {
|
||||
color: #f5f5f5; /* neutral-100 */
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
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);
|
||||
if (seconds < 60) return "just now";
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
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);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) return rtf.format(-hours, "hour");
|
||||
|
||||
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);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
if (months < 12) return rtf.format(-months, "month");
|
||||
|
||||
const years = Math.floor(months / 12);
|
||||
return `${years}y ago`;
|
||||
return rtf.format(-years, "year");
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const nf = new Intl.NumberFormat("en", { maximumFractionDigits: 1 });
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user