Comprehensive UX audit fixes: navigation, feedback, affordances, and accessibility
CI / build-and-push (push) Successful in 28s
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user