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:
duy 2026-01-29 15:33:58 -08:00
parent 146a55836c
commit b2c6b055ee
8 changed files with 71 additions and 20 deletions

View File

@ -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 />

View File

@ -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} />

View File

@ -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>

View File

@ -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>
)}

View File

@ -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">

View File

@ -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>
))}

View File

@ -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;
}
}

View File

@ -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 {