Comprehensive UX polish: fix 19 friction points across all pages
CI / build-and-push (push) Successful in 33s

Addresses broken interactions (notification bell, browser dialogs),
missing feedback states (disabled buttons, pricing changes, paused
indicator), unclear affordances (research queue, model tuning, funding
requirements), and navigation gaps (hash routing, keyboard shortcuts,
clickable dashboard cards, sidebar grouping, tutorial hints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 21:44:18 -04:00
parent d25dfe0435
commit f9f6233b69
19 changed files with 540 additions and 121 deletions
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { AlertTriangle } from 'lucide-react';
export function ConfirmModal({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelRef.current?.focus();
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onCancel]);
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-3">
{danger && <AlertTriangle size={20} className="text-danger shrink-0" />}
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="text-sm text-surface-400 mb-6">{message}</p>
<div className="flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200 hover:bg-surface-800"
>
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded text-sm font-medium ${
danger
? 'bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger'
: 'bg-accent hover:bg-accent-dark text-white'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell } from 'lucide-react';
import { useGameStore, type GameNotification } from '@/store';
import { formatDuration } from '@ai-tycoon/shared';
const ICON_MAP = {
success: { icon: CheckCircle, color: 'text-success' },
warning: { icon: AlertTriangle, color: 'text-warning' },
danger: { icon: AlertCircle, color: 'text-danger' },
info: { icon: Info, color: 'text-accent-light' },
} as const;
export function NotificationPanel({ onClose }: { onClose: () => void }) {
const notifications = useGameStore((s) => s.notifications);
const markAllRead = useGameStore((s) => s.markAllNotificationsRead);
const currentTick = useGameStore((s) => s.meta.tickCount);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
markAllRead();
}, [markAllRead]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [onClose]);
return (
<div
ref={panelRef}
className="absolute top-12 right-0 w-80 max-h-96 bg-surface-900 border border-surface-700 rounded-xl shadow-2xl z-50 overflow-hidden flex flex-col"
>
<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>
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="p-6 text-center text-surface-500">
<Bell size={24} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No notifications yet</p>
</div>
) : (
notifications.map((n: GameNotification) => {
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 className="flex items-start gap-2">
<Icon size={14} className={`${color} mt-0.5 shrink-0`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{n.title}</div>
<div className="text-xs text-surface-400">{n.message}</div>
<div className="text-xs text-surface-600 mt-1">{formatDuration(ticksAgo)} ago</div>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -2,6 +2,8 @@ import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer';
import { useGameStore } from '@/store';
import { useHashRouter } from '@/hooks/useHashRouter';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage';
@@ -16,6 +18,8 @@ import { AchievementsPage } from '@/pages/AchievementsPage';
import { LeaderboardPage } from '@/pages/LeaderboardPage';
export function MainLayout() {
useHashRouter();
useKeyboardShortcuts();
const activePage = useGameStore((s) => s.activePage);
return (
+18 -15
View File
@@ -70,22 +70,25 @@ export function Sidebar() {
const isActive = activePage === page;
const isNew = newPages.has(page);
const showDivider = page === 'talent' || page === 'achievements';
return (
<button
key={page}
onClick={() => handleNavClick(page)}
className={`w-full flex items-center 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'
}`}
>
<Icon size={18} />
{label}
{isNew && (
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
)}
</button>
<div key={page}>
{showDivider && <div className="border-t border-surface-700 my-1 mx-4" />}
<button
onClick={() => handleNavClick(page)}
className={`w-full flex items-center 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'
}`}
>
<Icon size={18} />
{label}
{isNew && (
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
)}
</button>
</div>
);
})}
</nav>
+22 -9
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useState } from 'react';
import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
import type { GameSpeed } from '@ai-tycoon/shared';
@@ -23,6 +24,7 @@ export function TopBar() {
const notifications = useGameStore((s) => s.notifications);
const unreadCount = notifications.filter(n => !n.read).length;
const [showStats, setShowStats] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const netIncome = revenuePerTick - expensesPerTick;
@@ -65,11 +67,16 @@ export function TopBar() {
</div>
<div className="flex items-center gap-3">
{isPaused && (
<span className="text-xs font-semibold text-warning bg-warning/10 border border-warning/30 px-2.5 py-1 rounded-lg animate-pulse">
PAUSED
</span>
)}
<div className="flex items-center gap-1 bg-surface-800 rounded-lg p-1">
<button
onClick={togglePause}
className="p-1.5 rounded hover:bg-surface-700 transition-colors"
title={isPaused ? 'Resume' : 'Pause'}
title={isPaused ? 'Resume (Space)' : 'Pause (Space)'}
>
{isPaused ? <Play size={16} /> : <Pause size={16} />}
</button>
@@ -96,14 +103,20 @@ export function TopBar() {
<Share2 size={18} />
</button>
<button className="relative p-2 rounded hover:bg-surface-800 transition-colors">
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded hover:bg-surface-800 transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
{showNotifications && <NotificationPanel onClose={() => setShowNotifications(false)} />}
</div>
</div>
{showStats && <CompanyStatsCard onClose={() => setShowStats(false)} />}