From 8d650fefaecb03d5d8f8bd6cca548ebbd341de0e Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 25 Apr 2026 09:05:26 -0400 Subject: [PATCH] Comprehensive UX audit fixes: navigation, feedback, affordances, and accessibility Address 18 issues across high/medium/low impact tiers identified in a full interface review. Key changes: Models page decomposed into tabs, confirmation dialogs for irreversible actions (deploy/open-source/acquire), chart Y-axes made visible, hash router extended for Market tab persistence, collapsible sidebar, keyboard navigation shortcuts (g+key chords), notification bulk actions, achievement progress bars, and ARIA label improvements. Co-Authored-By: Claude Opus 4.6 --- .../components/common/NotificationPanel.tsx | 22 +++-- apps/web/src/components/common/Tooltip.tsx | 2 +- apps/web/src/components/layout/MainLayout.tsx | 8 +- apps/web/src/components/layout/Sidebar.tsx | 53 ++++++++--- apps/web/src/components/layout/TopBar.tsx | 4 + apps/web/src/hooks/useHashRouter.ts | 35 +++++--- apps/web/src/hooks/useKeyboardShortcuts.ts | 36 +++++++- apps/web/src/pages/AchievementsPage.tsx | 41 ++++++++- apps/web/src/pages/CompetitorsPage.tsx | 24 ++++- apps/web/src/pages/DashboardPage.tsx | 21 +++-- apps/web/src/pages/FinancePage.tsx | 4 +- apps/web/src/pages/InfrastructurePage.tsx | 5 +- apps/web/src/pages/MarketPage.tsx | 39 +++++--- apps/web/src/pages/ModelsPage.tsx | 89 ++++++++++++++++--- apps/web/src/pages/ResearchPage.tsx | 4 +- apps/web/src/pages/SettingsPage.tsx | 19 ++-- apps/web/src/store/index.ts | 8 ++ 17 files changed, 332 insertions(+), 82 deletions(-) diff --git a/apps/web/src/components/common/NotificationPanel.tsx b/apps/web/src/components/common/NotificationPanel.tsx index 4a7e155..533cf02 100644 --- a/apps/web/src/components/common/NotificationPanel.tsx +++ b/apps/web/src/components/common/NotificationPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell } from 'lucide-react'; +import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react'; import { useGameStore, type GameNotification } from '@/store'; import { formatDuration } from '@ai-tycoon/shared'; @@ -13,6 +13,8 @@ const ICON_MAP = { export function NotificationPanel({ onClose }: { onClose: () => void }) { const notifications = useGameStore((s) => s.notifications); const markAllRead = useGameStore((s) => s.markAllNotificationsRead); + const removeNotification = useGameStore((s) => s.removeNotification); + const clearAll = useGameStore((s) => s.clearAllNotifications); const currentTick = useGameStore((s) => s.meta.tickCount); const panelRef = useRef(null); @@ -44,9 +46,16 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) { >

Notifications

- +
+ {notifications.length > 0 && ( + + )} + +
{notifications.length === 0 ? ( @@ -59,7 +68,7 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) { const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info; const ticksAgo = currentTick - n.tick; return ( -
+
@@ -67,6 +76,9 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) {
{n.message}
{formatDuration(ticksAgo)} ago
+
); diff --git a/apps/web/src/components/common/Tooltip.tsx b/apps/web/src/components/common/Tooltip.tsx index 1bfcb96..2225f8d 100644 --- a/apps/web/src/components/common/Tooltip.tsx +++ b/apps/web/src/components/common/Tooltip.tsx @@ -32,7 +32,7 @@ export function Tooltip({ content, children, position = 'bottom' }: TooltipProps {children} {visible && (
-
+
{content}
diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 2292182..0afe44e 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -19,7 +19,7 @@ import { AchievementsPage } from '@/pages/AchievementsPage'; import { LeaderboardPage } from '@/pages/LeaderboardPage'; export function MainLayout() { - useHashRouter(); + const { subPath, setSubPath } = useHashRouter(); useKeyboardShortcuts(); const activePage = useGameStore((s) => s.activePage); @@ -29,7 +29,7 @@ export function MainLayout() {
- +
@@ -38,13 +38,13 @@ export function MainLayout() { ); } -function PageRouter({ page }: { page: string }) { +function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: string | null; setSubPath: (s: string | null) => void }) { switch (page) { case 'dashboard': return ; case 'infrastructure': return ; case 'research': return ; case 'models': return ; - case 'market': return ; + case 'market': return ; case 'finance': return ; case 'talent': return ; case 'data': return ; diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 04a0a16..486a76f 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { LayoutDashboard, Server, FlaskConical, Brain, TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy, Medal, + PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; import { useGameStore, type ActivePage } from '@/store'; @@ -20,11 +21,20 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard { page: 'settings', label: 'Settings', icon: Settings }, ]; +function getInitialCollapsed(): boolean { + try { + const stored = localStorage.getItem('ai-tycoon-sidebar-collapsed'); + if (stored !== null) return stored === 'true'; + return window.innerWidth < 1280; + } catch { return false; } +} + export function Sidebar() { const activePage = useGameStore((s) => s.activePage); const setActivePage = useGameStore((s) => s.setActivePage); const companyName = useGameStore((s) => s.meta.companyName); const era = useGameStore((s) => s.meta.currentEra); + const [collapsed, setCollapsed] = useState(getInitialCollapsed); const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi']; const currentEraIdx = eraOrder.indexOf(era); @@ -55,13 +65,28 @@ export function Sidebar() { }); }; + const toggleCollapse = () => { + setCollapsed(prev => { + const next = !prev; + localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next)); + return next; + }); + }; + return ( -
@@ -84,7 +93,7 @@ export function CompetitorsPage() { return (
)} + + {acquireConfirm && ( + { acquireCompetitor(acquireConfirm.id); setAcquireConfirm(null); }} + onCancel={() => setAcquireConfirm(null)} + /> + )}
); } diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index fc7ab4a..47576b8 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -2,7 +2,7 @@ import { useGameStore } from '@/store'; import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared'; import { DollarSign, Server, Brain, Users, TrendingUp, - TrendingDown, Minus, Cpu, Zap, Shield, + TrendingDown, Minus, Cpu, Zap, Shield, ChevronRight, } from 'lucide-react'; import { XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts'; import { TutorialHint } from '@/components/game/TutorialHint'; @@ -94,7 +94,7 @@ export function DashboardPage() { - + formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> - + formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> [formatNumber(value), 'Subscribers']} @@ -178,7 +178,7 @@ export function DashboardPage() { - + `${v}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> [`${value}/100`, 'Reputation']} @@ -225,12 +225,15 @@ function StatCard({ }) { return (
-
- - {label} +
+
+ + {label} +
+ {onClick && }
{value}
{subValue && ( @@ -256,12 +259,14 @@ function StatusRow({ bar: number; barColor: string; }) { + const severity = barColor.includes('danger') ? 'Critical' : barColor.includes('warning') ? 'Warning' : null; return (
{label} + {severity && {severity}}
{value}
diff --git a/apps/web/src/pages/FinancePage.tsx b/apps/web/src/pages/FinancePage.tsx index b0a32fc..99580f8 100644 --- a/apps/web/src/pages/FinancePage.tsx +++ b/apps/web/src/pages/FinancePage.tsx @@ -77,7 +77,7 @@ export function FinancePage() { - + formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> @@ -100,7 +100,7 @@ export function FinancePage() { - + formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']} diff --git a/apps/web/src/pages/InfrastructurePage.tsx b/apps/web/src/pages/InfrastructurePage.tsx index 1ef255d..540637f 100644 --- a/apps/web/src/pages/InfrastructurePage.tsx +++ b/apps/web/src/pages/InfrastructurePage.tsx @@ -233,7 +233,10 @@ function ClustersListView() { + ))} +
+ + {/* Compute Allocation — always visible */}

Compute Allocation

@@ -164,7 +188,7 @@ export function ModelsPage() {
{/* Active Training Pipelines */} - {activePipelines.length > 0 && ( + {modelsTab === 'overview' && activePipelines.length > 0 && (

Active Training

{activePipelines.map(pipeline => { @@ -259,7 +283,7 @@ export function ModelsPage() { )} {/* Active Variant Jobs */} - {activeVariantJobs.length > 0 && ( + {modelsTab === 'overview' && activeVariantJobs.length > 0 && (

Variant Jobs

{activeVariantJobs.map(job => { @@ -284,7 +308,7 @@ export function ModelsPage() { )} {/* Active Eval Jobs */} - {activeEvalJobs.length > 0 && ( + {modelsTab === 'overview' && activeEvalJobs.length > 0 && (

Running Evaluations

{activeEvalJobs.map(job => { @@ -306,7 +330,7 @@ export function ModelsPage() { )} {/* Train New Model */} -
+ {modelsTab === 'train' &&

Train New Model

@@ -382,6 +406,7 @@ export function ModelsPage() { ))}
+

Total must equal 100% — other values adjust proportionally.

{(Object.keys(DOMAIN_LABELS) as DataDomain[]).map(domain => (
@@ -438,10 +463,10 @@ export function ModelsPage() { )}
-
+
} {/* Model Families & Trained Models */} - {families.length > 0 && ( + {modelsTab === 'models' && families.length > 0 && (

Model Families

{families.map(family => { @@ -525,7 +550,7 @@ export function ModelsPage() { )} {/* Benchmark Leaderboard */} - {benchmarkResults.length > 0 && ( + {modelsTab === 'benchmarks' && benchmarkResults.length > 0 && ( )} + {modelsTab === 'benchmarks' && benchmarkResults.length === 0 && ( +
+ No benchmark results yet. Run evaluations from the Models tab. +
+ )} {/* Product Lines */} -
+ {modelsTab === 'products' &&

Product Lines

{productLines.map(pl => (
@@ -552,7 +582,21 @@ export function ModelsPage() {
))} -
+
} + + {/* Empty state for Models tab */} + {modelsTab === 'models' && families.length === 0 && ( +
+ No model families yet. Train your first model from the Train New tab. +
+ )} + + {/* Empty state for Overview when nothing is active */} + {modelsTab === 'overview' && activePipelines.length === 0 && activeVariantJobs.length === 0 && activeEvalJobs.length === 0 && ( +
+ No active jobs. Start a training pipeline from the Train New tab. +
+ )}
); } @@ -563,10 +607,12 @@ function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: { onDeploy: () => void; onOpenSource: () => void; }) { + const [confirmAction, setConfirmAction] = useState<'deploy' | 'opensource' | null>(null); + return ( <> {!isOpenSourced && model.isDeployed && ( - @@ -574,11 +620,30 @@ function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: { {model.isDeployed ? ( Deployed ) : ( - )} + {confirmAction === 'deploy' && ( + { onDeploy(); setConfirmAction(null); }} + onCancel={() => setConfirmAction(null)} + /> + )} + {confirmAction === 'opensource' && ( + { onOpenSource(); setConfirmAction(null); }} + onCancel={() => setConfirmAction(null)} + /> + )} ); } diff --git a/apps/web/src/pages/ResearchPage.tsx b/apps/web/src/pages/ResearchPage.tsx index 9c77661..e9032f0 100644 --- a/apps/web/src/pages/ResearchPage.tsx +++ b/apps/web/src/pages/ResearchPage.tsx @@ -105,10 +105,12 @@ export function ResearchPage() { return (
isAvailable && !activeResearch && handleStart(node)} className={`rounded-xl border p-4 transition-all ${ isCompleted ? 'border-success/50 bg-success/5 opacity-70' : isActive ? 'border-accent/50 bg-accent/5' : - isAvailable ? `${CATEGORY_COLORS[category]} hover:brightness-110` : + isAvailable && !activeResearch ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` : + isAvailable ? `${CATEGORY_COLORS[category]}` : 'border-surface-700 bg-surface-900 opacity-50' }`} > diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index d6eee89..a0e2084 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -83,14 +83,17 @@ export function SettingsPage() {
Music Volume
Background music level
- setMusicVolume(Number(e.target.value) / 100)} - className="w-32 accent-accent" - /> +
+ setMusicVolume(Number(e.target.value) / 100)} + className="w-32 accent-accent" + /> + {Math.round(settings.musicVolume * 100)}% +
diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 559a91d..87b5b30 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -88,6 +88,8 @@ interface Actions { setInfraNav: (nav: InfraNav) => void; addNotification: (n: Omit) => void; dismissNotification: (id: string) => void; + removeNotification: (id: string) => void; + clearAllNotifications: () => void; markAllNotificationsRead: () => void; startNewGame: (companyName: string) => void; setGameSpeed: (speed: GameSpeed) => void; @@ -305,6 +307,12 @@ export const useGameStore = create()( ), })), + removeNotification: (id) => set((s) => ({ + notifications: s.notifications.filter(n => n.id !== id), + })), + + clearAllNotifications: () => set({ notifications: [] }), + markAllNotificationsRead: () => set((s) => ({ notifications: s.notifications.map(n => ({ ...n, read: true })), })),