Comprehensive UX audit fixes: navigation, feedback, affordances, and accessibility
CI / build-and-push (push) Successful in 28s

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 09:05:26 -04:00
parent 09a5cb69a7
commit 8d650fefae
17 changed files with 332 additions and 82 deletions
@@ -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<HTMLDivElement>(null);
@@ -44,9 +46,16 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) {
>
<div className="px-4 py-3 border-b border-surface-700 flex items-center justify-between shrink-0">
<h3 className="text-sm font-semibold">Notifications</h3>
<button onClick={onClose} className="text-surface-400 hover:text-surface-200">
<X size={14} />
</button>
<div className="flex items-center gap-2">
{notifications.length > 0 && (
<button onClick={clearAll} className="text-surface-500 hover:text-surface-300 text-[10px]" title="Clear all">
<Trash2 size={12} />
</button>
)}
<button onClick={onClose} className="text-surface-400 hover:text-surface-200">
<X size={14} />
</button>
</div>
</div>
<div className="overflow-y-auto flex-1">
{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 (
<div key={n.id} className="px-4 py-3 border-b border-surface-800 last:border-0 hover:bg-surface-800/50">
<div key={n.id} className="px-4 py-3 border-b border-surface-800 last:border-0 hover:bg-surface-800/50 group">
<div className="flex items-start gap-2">
<Icon size={14} className={`${color} mt-0.5 shrink-0`} />
<div className="flex-1 min-w-0">
@@ -67,6 +76,9 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) {
<div className="text-xs text-surface-400">{n.message}</div>
<div className="text-xs text-surface-600 mt-1">{formatDuration(ticksAgo)} ago</div>
</div>
<button onClick={() => removeNotification(n.id)} className="text-surface-600 hover:text-surface-300 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5">
<X size={12} />
</button>
</div>
</div>
);
+1 -1
View File
@@ -32,7 +32,7 @@ export function Tooltip({ content, children, position = 'bottom' }: TooltipProps
{children}
{visible && (
<div className={`absolute z-50 ${positionClasses[position]} pointer-events-none`}>
<div className="bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-xs text-surface-200 shadow-xl whitespace-nowrap max-w-xs">
<div className="bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-xs text-surface-200 shadow-xl max-w-xs break-words">
{content}
</div>
</div>
@@ -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() {
<div className="flex-1 flex flex-col overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
<PageRouter page={activePage} />
<PageRouter page={activePage} subPath={subPath} setSubPath={setSubPath} />
</main>
</div>
<ToastContainer />
@@ -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 <DashboardPage />;
case 'infrastructure': return <InfrastructurePage />;
case 'research': return <ResearchPage />;
case 'models': return <ModelsPage />;
case 'market': return <MarketPage />;
case 'market': return <MarketPage initialTab={subPath} onTabChange={setSubPath} />;
case 'finance': return <FinancePage />;
case 'talent': return <TalentPage />;
case 'data': return <DataPage />;
+41 -12
View File
@@ -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 (
<aside className="w-56 bg-surface-900 border-r border-surface-700 flex flex-col h-screen">
<div className="p-4 border-b border-surface-700">
<h1 className="text-lg font-bold text-accent-light truncate">{companyName}</h1>
<span className="text-xs text-surface-400 uppercase tracking-wider">
{era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI Era'}
</span>
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
{!collapsed && (
<div className="min-w-0">
<h1 className="text-lg font-bold text-accent-light truncate">{companyName}</h1>
<span className="text-xs text-surface-400 uppercase tracking-wider">
{era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI Era'}
</span>
</div>
)}
<button onClick={toggleCollapse} className="text-surface-400 hover:text-surface-200 shrink-0 p-1" aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
</button>
</div>
<nav className="flex-1 py-2 overflow-y-auto">
@@ -73,28 +98,32 @@ export function Sidebar() {
const showDivider = page === 'talent' || page === 'achievements';
return (
<div key={page}>
{showDivider && <div className="border-t border-surface-700 my-1 mx-4" />}
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
<button
onClick={() => handleNavClick(page)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
className={`w-full flex items-center ${collapsed ? 'justify-center px-2' : 'gap-3 px-4'} py-2.5 text-sm transition-colors ${
isActive
? 'bg-accent/10 text-accent-light border-r-2 border-accent'
: 'text-surface-300 hover:bg-surface-800 hover:text-surface-100'
}`}
title={collapsed ? label : undefined}
>
<Icon size={18} />
{label}
{isNew && (
{!collapsed && label}
{!collapsed && isNew && (
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
)}
{collapsed && isNew && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 bg-accent rounded-full" />
)}
</button>
</div>
);
})}
</nav>
<div className="p-4 border-t border-surface-700 text-xs text-surface-500">
AI Tycoon v0.1
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
{collapsed ? 'v0.1' : 'AI Tycoon v0.1'}
</div>
</aside>
);
@@ -77,6 +77,7 @@ export function TopBar() {
onClick={togglePause}
className="p-1.5 rounded hover:bg-surface-700 transition-colors"
title={isPaused ? 'Resume (Space)' : 'Pause (Space)'}
aria-label={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play size={16} /> : <Pause size={16} />}
</button>
@@ -89,6 +90,7 @@ export function TopBar() {
? 'bg-accent text-white'
: 'hover:bg-surface-700 text-surface-400'
}`}
aria-label={`Set speed to ${speed}x`}
>
{speed}x
</button>
@@ -99,6 +101,7 @@ export function TopBar() {
onClick={() => setShowStats(true)}
className="p-2 rounded hover:bg-surface-800 transition-colors"
title="Share Stats"
aria-label="Share Stats"
>
<Share2 size={18} />
</button>
@@ -107,6 +110,7 @@ export function TopBar() {
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded hover:bg-surface-800 transition-colors"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
<Bell size={18} />
{unreadCount > 0 && (