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 { 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 { useGameStore, type GameNotification } from '@/store';
import { formatDuration } from '@ai-tycoon/shared'; import { formatDuration } from '@ai-tycoon/shared';
@@ -13,6 +13,8 @@ const ICON_MAP = {
export function NotificationPanel({ onClose }: { onClose: () => void }) { export function NotificationPanel({ onClose }: { onClose: () => void }) {
const notifications = useGameStore((s) => s.notifications); const notifications = useGameStore((s) => s.notifications);
const markAllRead = useGameStore((s) => s.markAllNotificationsRead); 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 currentTick = useGameStore((s) => s.meta.tickCount);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -44,10 +46,17 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) {
> >
<div className="px-4 py-3 border-b border-surface-700 flex items-center justify-between shrink-0"> <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> <h3 className="text-sm font-semibold">Notifications</h3>
<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"> <button onClick={onClose} className="text-surface-400 hover:text-surface-200">
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
</div>
<div className="overflow-y-auto flex-1"> <div className="overflow-y-auto flex-1">
{notifications.length === 0 ? ( {notifications.length === 0 ? (
<div className="p-6 text-center text-surface-500"> <div className="p-6 text-center text-surface-500">
@@ -59,7 +68,7 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) {
const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info; const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info;
const ticksAgo = currentTick - n.tick; const ticksAgo = currentTick - n.tick;
return ( 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"> <div className="flex items-start gap-2">
<Icon size={14} className={`${color} mt-0.5 shrink-0`} /> <Icon size={14} className={`${color} mt-0.5 shrink-0`} />
<div className="flex-1 min-w-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-400">{n.message}</div>
<div className="text-xs text-surface-600 mt-1">{formatDuration(ticksAgo)} ago</div> <div className="text-xs text-surface-600 mt-1">{formatDuration(ticksAgo)} ago</div>
</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>
</div> </div>
); );
+1 -1
View File
@@ -32,7 +32,7 @@ export function Tooltip({ content, children, position = 'bottom' }: TooltipProps
{children} {children}
{visible && ( {visible && (
<div className={`absolute z-50 ${positionClasses[position]} pointer-events-none`}> <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} {content}
</div> </div>
</div> </div>
@@ -19,7 +19,7 @@ import { AchievementsPage } from '@/pages/AchievementsPage';
import { LeaderboardPage } from '@/pages/LeaderboardPage'; import { LeaderboardPage } from '@/pages/LeaderboardPage';
export function MainLayout() { export function MainLayout() {
useHashRouter(); const { subPath, setSubPath } = useHashRouter();
useKeyboardShortcuts(); useKeyboardShortcuts();
const activePage = useGameStore((s) => s.activePage); const activePage = useGameStore((s) => s.activePage);
@@ -29,7 +29,7 @@ export function MainLayout() {
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<TopBar /> <TopBar />
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
<PageRouter page={activePage} /> <PageRouter page={activePage} subPath={subPath} setSubPath={setSubPath} />
</main> </main>
</div> </div>
<ToastContainer /> <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) { switch (page) {
case 'dashboard': return <DashboardPage />; case 'dashboard': return <DashboardPage />;
case 'infrastructure': return <InfrastructurePage />; case 'infrastructure': return <InfrastructurePage />;
case 'research': return <ResearchPage />; case 'research': return <ResearchPage />;
case 'models': return <ModelsPage />; case 'models': return <ModelsPage />;
case 'market': return <MarketPage />; case 'market': return <MarketPage initialTab={subPath} onTabChange={setSubPath} />;
case 'finance': return <FinancePage />; case 'finance': return <FinancePage />;
case 'talent': return <TalentPage />; case 'talent': return <TalentPage />;
case 'data': return <DataPage />; case 'data': return <DataPage />;
+37 -8
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { import {
LayoutDashboard, Server, FlaskConical, Brain, LayoutDashboard, Server, FlaskConical, Brain,
TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy, Medal, TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
PanelLeftClose, PanelLeftOpen,
} from 'lucide-react'; } from 'lucide-react';
import { useGameStore, type ActivePage } from '@/store'; 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 }, { 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() { export function Sidebar() {
const activePage = useGameStore((s) => s.activePage); const activePage = useGameStore((s) => s.activePage);
const setActivePage = useGameStore((s) => s.setActivePage); const setActivePage = useGameStore((s) => s.setActivePage);
const companyName = useGameStore((s) => s.meta.companyName); const companyName = useGameStore((s) => s.meta.companyName);
const era = useGameStore((s) => s.meta.currentEra); const era = useGameStore((s) => s.meta.currentEra);
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi']; const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(era); const currentEraIdx = eraOrder.indexOf(era);
@@ -55,14 +65,29 @@ export function Sidebar() {
}); });
}; };
const toggleCollapse = () => {
setCollapsed(prev => {
const next = !prev;
localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next));
return next;
});
};
return ( return (
<aside className="w-56 bg-surface-900 border-r border-surface-700 flex flex-col h-screen"> <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="p-4 border-b border-surface-700"> <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> <h1 className="text-lg font-bold text-accent-light truncate">{companyName}</h1>
<span className="text-xs text-surface-400 uppercase tracking-wider"> <span className="text-xs text-surface-400 uppercase tracking-wider">
{era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI Era'} {era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI Era'}
</span> </span>
</div> </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"> <nav className="flex-1 py-2 overflow-y-auto">
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => { {NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => {
@@ -73,28 +98,32 @@ export function Sidebar() {
const showDivider = page === 'talent' || page === 'achievements'; const showDivider = page === 'talent' || page === 'achievements';
return ( return (
<div key={page}> <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 <button
onClick={() => handleNavClick(page)} 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 isActive
? 'bg-accent/10 text-accent-light border-r-2 border-accent' ? 'bg-accent/10 text-accent-light border-r-2 border-accent'
: 'text-surface-300 hover:bg-surface-800 hover:text-surface-100' : 'text-surface-300 hover:bg-surface-800 hover:text-surface-100'
}`} }`}
title={collapsed ? label : undefined}
> >
<Icon size={18} /> <Icon size={18} />
{label} {!collapsed && label}
{isNew && ( {!collapsed && isNew && (
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span> <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> </button>
</div> </div>
); );
})} })}
</nav> </nav>
<div className="p-4 border-t border-surface-700 text-xs text-surface-500"> <div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
AI Tycoon v0.1 {collapsed ? 'v0.1' : 'AI Tycoon v0.1'}
</div> </div>
</aside> </aside>
); );
@@ -77,6 +77,7 @@ export function TopBar() {
onClick={togglePause} onClick={togglePause}
className="p-1.5 rounded hover:bg-surface-700 transition-colors" className="p-1.5 rounded hover:bg-surface-700 transition-colors"
title={isPaused ? 'Resume (Space)' : 'Pause (Space)'} title={isPaused ? 'Resume (Space)' : 'Pause (Space)'}
aria-label={isPaused ? 'Resume' : 'Pause'}
> >
{isPaused ? <Play size={16} /> : <Pause size={16} />} {isPaused ? <Play size={16} /> : <Pause size={16} />}
</button> </button>
@@ -89,6 +90,7 @@ export function TopBar() {
? 'bg-accent text-white' ? 'bg-accent text-white'
: 'hover:bg-surface-700 text-surface-400' : 'hover:bg-surface-700 text-surface-400'
}`} }`}
aria-label={`Set speed to ${speed}x`}
> >
{speed}x {speed}x
</button> </button>
@@ -99,6 +101,7 @@ export function TopBar() {
onClick={() => setShowStats(true)} onClick={() => setShowStats(true)}
className="p-2 rounded hover:bg-surface-800 transition-colors" className="p-2 rounded hover:bg-surface-800 transition-colors"
title="Share Stats" title="Share Stats"
aria-label="Share Stats"
> >
<Share2 size={18} /> <Share2 size={18} />
</button> </button>
@@ -107,6 +110,7 @@ export function TopBar() {
<button <button
onClick={() => setShowNotifications(!showNotifications)} onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded hover:bg-surface-800 transition-colors" className="relative p-2 rounded hover:bg-surface-800 transition-colors"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
> >
<Bell size={18} /> <Bell size={18} />
{unreadCount > 0 && ( {unreadCount > 0 && (
+25 -10
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useGameStore, type ActivePage } from '@/store'; import { useGameStore, type ActivePage } from '@/store';
const VALID_PAGES = new Set<ActivePage>([ const VALID_PAGES = new Set<ActivePage>([
@@ -7,32 +7,47 @@ const VALID_PAGES = new Set<ActivePage>([
'finance', 'achievements', 'leaderboard', 'settings', 'finance', 'achievements', 'leaderboard', 'settings',
]); ]);
function parseHash(): { page: ActivePage | null; subPath: string | null } {
const raw = window.location.hash.slice(1);
const [page, subPath] = raw.split('/') as [string, string | undefined];
return {
page: VALID_PAGES.has(page as ActivePage) ? (page as ActivePage) : null,
subPath: subPath || null,
};
}
export function useHashRouter() { export function useHashRouter() {
const activePage = useGameStore((s) => s.activePage); const activePage = useGameStore((s) => s.activePage);
const setActivePage = useGameStore((s) => s.setActivePage); const setActivePage = useGameStore((s) => s.setActivePage);
const [subPath, setSubPath] = useState<string | null>(() => parseHash().subPath);
useEffect(() => { useEffect(() => {
const hash = window.location.hash.slice(1) as ActivePage; const { page, subPath: sub } = parseHash();
if (hash && VALID_PAGES.has(hash) && hash !== activePage) { if (page && page !== activePage) {
setActivePage(hash); setActivePage(page);
} }
setSubPath(sub);
}, []); }, []);
useEffect(() => { useEffect(() => {
const current = window.location.hash.slice(1); const current = window.location.hash.slice(1);
if (current !== activePage) { const expected = subPath ? `${activePage}/${subPath}` : activePage;
window.history.pushState(null, '', `#${activePage}`); if (current !== expected) {
window.history.pushState(null, '', `#${expected}`);
} }
}, [activePage]); }, [activePage, subPath]);
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
const hash = window.location.hash.slice(1) as ActivePage; const { page, subPath: sub } = parseHash();
if (hash && VALID_PAGES.has(hash)) { if (page) {
setActivePage(hash); setActivePage(page);
} }
setSubPath(sub);
}; };
window.addEventListener('hashchange', handler); window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler); return () => window.removeEventListener('hashchange', handler);
}, [setActivePage]); }, [setActivePage]);
return { subPath, setSubPath };
} }
+33 -3
View File
@@ -1,9 +1,23 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useGameStore } from '@/store'; import { useGameStore, type ActivePage } from '@/store';
import type { GameSpeed } from '@ai-tycoon/shared'; import type { GameSpeed } from '@ai-tycoon/shared';
const PAGE_SHORTCUTS: Record<string, ActivePage> = {
d: 'dashboard',
i: 'infrastructure',
r: 'research',
m: 'models',
k: 'market',
f: 'finance',
t: 'talent',
s: 'settings',
};
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
let gPressed = false;
let gTimer: ReturnType<typeof setTimeout>;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'd') { if (e.ctrlKey && e.key === 'd') {
e.preventDefault(); e.preventDefault();
@@ -16,6 +30,17 @@ export function useKeyboardShortcuts() {
const store = useGameStore.getState(); const store = useGameStore.getState();
if (gPressed) {
gPressed = false;
clearTimeout(gTimer);
const page = PAGE_SHORTCUTS[e.key];
if (page) {
e.preventDefault();
store.setActivePage(page);
}
return;
}
switch (e.key) { switch (e.key) {
case ' ': case ' ':
e.preventDefault(); e.preventDefault();
@@ -30,11 +55,16 @@ export function useKeyboardShortcuts() {
case '3': case '3':
store.setGameSpeed(5 as GameSpeed); store.setGameSpeed(5 as GameSpeed);
break; break;
case 'Escape': case 'g':
gPressed = true;
gTimer = setTimeout(() => { gPressed = false; }, 500);
break; break;
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => {
window.removeEventListener('keydown', handler);
clearTimeout(gTimer);
};
}, []); }, []);
} }
+40 -1
View File
@@ -1,10 +1,12 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine'; import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
import { formatNumber } from '@ai-tycoon/shared';
import { import {
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users, Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical, Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
GitBranch, Zap, GitBranch, Zap,
} from 'lucide-react'; } from 'lucide-react';
import type { AchievementCondition } from '@ai-tycoon/shared';
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = { const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users, Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users,
@@ -12,9 +14,34 @@ const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?:
GitBranch, Zap, GitBranch, Zap,
}; };
function resolveField(state: Record<string, unknown>, path: string): number {
const parts = path.split('.');
let current: unknown = state;
for (const part of parts) {
if (current == null || typeof current !== 'object') return 0;
current = (current as Record<string, unknown>)[part];
}
return typeof current === 'number' ? current : 0;
}
function getProgress(state: Record<string, unknown>, condition: AchievementCondition): { current: number; target: number; pct: number } | null {
if (condition.field.startsWith('meta._')) return null;
if (condition.operator === 'gt' && condition.value === 0) {
const current = resolveField(state, condition.field);
return { current, target: 1, pct: current > 0 ? 100 : 0 };
}
if (condition.operator === 'gte' || condition.operator === 'eq') {
const current = resolveField(state, condition.field);
const pct = condition.value > 0 ? Math.min(100, (current / condition.value) * 100) : (current > 0 ? 100 : 0);
return { current, target: condition.value, pct };
}
return null;
}
export function AchievementsPage() { export function AchievementsPage() {
const unlocked = useGameStore((s) => s.achievements.unlocked); const unlocked = useGameStore((s) => s.achievements.unlocked);
const unlockedIds = new Set(unlocked.map(a => a.id)); const unlockedIds = new Set(unlocked.map(a => a.id));
const state = useGameStore.getState() as unknown as Record<string, unknown>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -29,6 +56,7 @@ export function AchievementsPage() {
{ACHIEVEMENT_DEFINITIONS.map(def => { {ACHIEVEMENT_DEFINITIONS.map(def => {
const isUnlocked = unlockedIds.has(def.id); const isUnlocked = unlockedIds.has(def.id);
const IconComponent = ICON_MAP[def.icon] ?? Trophy; const IconComponent = ICON_MAP[def.icon] ?? Trophy;
const progress = !isUnlocked ? getProgress(state, def.condition) : null;
return ( return (
<div <div
@@ -43,12 +71,23 @@ export function AchievementsPage() {
<div className={`p-2 rounded-lg ${isUnlocked ? 'bg-accent/20 text-accent-light' : 'bg-surface-800 text-surface-500'}`}> <div className={`p-2 rounded-lg ${isUnlocked ? 'bg-accent/20 text-accent-light' : 'bg-surface-800 text-surface-500'}`}>
{isUnlocked ? <IconComponent size={20} /> : <Lock size={20} />} {isUnlocked ? <IconComponent size={20} /> : <Lock size={20} />}
</div> </div>
<div> <div className="flex-1 min-w-0">
<h4 className={`font-semibold text-sm ${isUnlocked ? '' : 'text-surface-400'}`}>{def.name}</h4> <h4 className={`font-semibold text-sm ${isUnlocked ? '' : 'text-surface-400'}`}>{def.name}</h4>
<p className="text-xs text-surface-400 mt-0.5">{def.description}</p> <p className="text-xs text-surface-400 mt-0.5">{def.description}</p>
{isUnlocked && ( {isUnlocked && (
<p className="text-xs text-accent mt-1">Unlocked</p> <p className="text-xs text-accent mt-1">Unlocked</p>
)} )}
{!isUnlocked && progress && progress.target > 0 && (
<div className="mt-2">
<div className="flex justify-between text-[10px] text-surface-500 mb-0.5">
<span>{formatNumber(progress.current)} / {formatNumber(progress.target)}</span>
<span>{Math.floor(progress.pct)}%</span>
</div>
<div className="h-1 bg-surface-700 rounded-full overflow-hidden">
<div className="h-full bg-accent/50 rounded-full transition-all" style={{ width: `${progress.pct}%` }} />
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+22 -2
View File
@@ -1,5 +1,8 @@
import { useState } from 'react';
import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-react'; import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal';
import { Tooltip } from '@/components/common/Tooltip';
import { formatMoney, formatNumber } from '@ai-tycoon/shared'; import { formatMoney, formatNumber } from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/shared'; import type { Era } from '@ai-tycoon/shared';
@@ -27,6 +30,7 @@ export function CompetitorsPage() {
const money = useGameStore((s) => s.economy.money); const money = useGameStore((s) => s.economy.money);
const acquireCompetitor = useGameStore((s) => s.acquireCompetitor); const acquireCompetitor = useGameStore((s) => s.acquireCompetitor);
const canAcquire = (era: Era) => era === 'bigtech' || era === 'agi'; const canAcquire = (era: Era) => era === 'bigtech' || era === 'agi';
const [acquireConfirm, setAcquireConfirm] = useState<{ id: string; name: string; cost: number } | null>(null);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -55,10 +59,15 @@ export function CompetitorsPage() {
key={rival.id} key={rival.id}
className="absolute top-0 h-full w-0.5 bg-danger" className="absolute top-0 h-full w-0.5 bg-danger"
style={{ left: `${rival.estimatedCapability}%` }} style={{ left: `${rival.estimatedCapability}%` }}
title={`${rival.name}: ${rival.estimatedCapability.toFixed(1)}`}
/> />
))} ))}
</div> </div>
<div className="flex items-center gap-4 mt-1.5 text-[10px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent inline-block" />You: {playerBest.toFixed(1)}</span>
{rivals.filter(r => r.status === 'active').map(rival => (
<span key={rival.id} className="flex items-center gap-1"><span className="w-2 h-0.5 bg-danger inline-block" />{rival.name}: {rival.estimatedCapability.toFixed(1)}</span>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -84,7 +93,7 @@ export function CompetitorsPage() {
return ( return (
<div className="text-right"> <div className="text-right">
<button <button
onClick={() => acquireCompetitor(rival.id)} onClick={() => setAcquireConfirm({ id: rival.id, name: rival.name, cost })}
disabled={money < cost} disabled={money < cost}
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
> >
@@ -134,6 +143,17 @@ export function CompetitorsPage() {
<p>No competitors detected yet.</p> <p>No competitors detected yet.</p>
</div> </div>
)} )}
{acquireConfirm && (
<ConfirmModal
title="Acquire Competitor"
message={`Acquire "${acquireConfirm.name}" for ${formatMoney(acquireConfirm.cost)}? This will absorb their users and technology. This cannot be undone.`}
confirmLabel={`Acquire (${formatMoney(acquireConfirm.cost)})`}
danger
onConfirm={() => { acquireCompetitor(acquireConfirm.id); setAcquireConfirm(null); }}
onCancel={() => setAcquireConfirm(null)}
/>
)}
</div> </div>
); );
} }
+11 -6
View File
@@ -2,7 +2,7 @@ import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared'; import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
import { import {
DollarSign, Server, Brain, Users, TrendingUp, DollarSign, Server, Brain, Users, TrendingUp,
TrendingDown, Minus, Cpu, Zap, Shield, TrendingDown, Minus, Cpu, Zap, Shield, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts'; import { XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
import { TutorialHint } from '@/components/game/TutorialHint'; import { TutorialHint } from '@/components/game/TutorialHint';
@@ -94,7 +94,7 @@ export function DashboardPage() {
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="tick" hide /> <XAxis dataKey="tick" hide />
<YAxis hide /> <YAxis width={50} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip <Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#94a3b8' }} labelStyle={{ color: '#94a3b8' }}
@@ -151,7 +151,7 @@ export function DashboardPage() {
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="tick" hide /> <XAxis dataKey="tick" hide />
<YAxis hide /> <YAxis width={50} tickFormatter={(v: number) => formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip <Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number) => [formatNumber(value), 'Subscribers']} formatter={(value: number) => [formatNumber(value), 'Subscribers']}
@@ -178,7 +178,7 @@ export function DashboardPage() {
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="tick" hide /> <XAxis dataKey="tick" hide />
<YAxis hide domain={[0, 100]} /> <YAxis width={30} domain={[0, 100]} tickFormatter={(v: number) => `${v}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip <Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number) => [`${value}/100`, 'Reputation']} formatter={(value: number) => [`${value}/100`, 'Reputation']}
@@ -225,13 +225,16 @@ function StatCard({
}) { }) {
return ( return (
<div <div
className={`bg-surface-900 border border-surface-700 rounded-xl p-4 ${onClick ? 'cursor-pointer hover:border-accent/30 transition-colors' : ''}`} className={`bg-surface-900 border border-surface-700 rounded-xl p-4 ${onClick ? 'cursor-pointer hover:border-accent/50 transition-colors group' : ''}`}
onClick={onClick} onClick={onClick}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icon size={16} className={color ?? 'text-surface-400'} /> <Icon size={16} className={color ?? 'text-surface-400'} />
<span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span> <span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span>
</div> </div>
{onClick && <ChevronRight size={14} className="text-surface-500 group-hover:text-accent-light group-hover:translate-x-0.5 transition-all" />}
</div>
<div className="text-2xl font-bold font-mono">{value}</div> <div className="text-2xl font-bold font-mono">{value}</div>
{subValue && ( {subValue && (
<div className={`text-xs mt-1 flex items-center gap-1 ${ <div className={`text-xs mt-1 flex items-center gap-1 ${
@@ -256,12 +259,14 @@ function StatusRow({
bar: number; bar: number;
barColor: string; barColor: string;
}) { }) {
const severity = barColor.includes('danger') ? 'Critical' : barColor.includes('warning') ? 'Warning' : null;
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon size={14} className="text-surface-400" /> <Icon size={14} className="text-surface-400" />
<span className="text-sm text-surface-300">{label}</span> <span className="text-sm text-surface-300">{label}</span>
{severity && <span className={`text-[10px] px-1.5 py-0.5 rounded ${barColor.includes('danger') ? 'bg-danger/20 text-danger' : 'bg-warning/20 text-warning'}`}>{severity}</span>}
</div> </div>
<span className="text-sm font-mono text-surface-200">{value}</span> <span className="text-sm font-mono text-surface-200">{value}</span>
</div> </div>
+2 -2
View File
@@ -77,7 +77,7 @@ export function FinancePage() {
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="tick" hide /> <XAxis dataKey="tick" hide />
<YAxis hide /> <YAxis width={50} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="money" stroke="#6366f1" fill="url(#cashGrad)" /> <Area type="monotone" dataKey="money" stroke="#6366f1" fill="url(#cashGrad)" />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -100,7 +100,7 @@ export function FinancePage() {
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<LineChart data={history}> <LineChart data={history}>
<XAxis dataKey="tick" hide /> <XAxis dataKey="tick" hide />
<YAxis hide /> <YAxis width={50} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip <Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']} formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']}
+4 -1
View File
@@ -233,7 +233,10 @@ function ClustersListView() {
<button <button
key={cluster.id} key={cluster.id}
onClick={() => cluster.status === 'operational' && setNav({ level: 'cluster', clusterId: cluster.id })} onClick={() => cluster.status === 'operational' && setNav({ level: 'cluster', clusterId: cluster.id })}
className="bg-surface-800 border border-surface-700 rounded-xl p-5 text-left hover:border-accent/50 transition-colors" className={`bg-surface-800 border border-surface-700 rounded-xl p-5 text-left transition-colors ${
cluster.status === 'operational' ? 'hover:border-accent/50 cursor-pointer' : 'cursor-default'
}`}
title={cluster.status === 'constructing' ? 'Available when construction completes' : undefined}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+27 -12
View File
@@ -25,19 +25,26 @@ const TABS: { id: MarketTab; label: string }[] = [
]; ];
function useAppliedFeedback() { function useAppliedFeedback() {
const [show, setShow] = useState(false); const [state, setState] = useState<'hidden' | 'valid' | 'invalid'>('hidden');
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const trigger = useCallback(() => { const trigger = useCallback((valid = true) => {
setShow(true); setState(valid ? 'valid' : 'invalid');
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setShow(false), 1200); timerRef.current = setTimeout(() => setState('hidden'), 1200);
}, []); }, []);
useEffect(() => () => clearTimeout(timerRef.current), []); useEffect(() => () => clearTimeout(timerRef.current), []);
return { show, trigger }; return { show: state !== 'hidden', valid: state === 'valid', trigger };
} }
function AppliedBadge({ visible }: { visible: boolean }) { function AppliedBadge({ visible, valid = true }: { visible: boolean; valid?: boolean }) {
if (!visible) return null; if (!visible) return null;
if (!valid) {
return (
<span className="inline-flex items-center gap-1 text-[10px] text-danger ml-2 animate-pulse">
Invalid
</span>
);
}
return ( return (
<span className="inline-flex items-center gap-1 text-[10px] text-success ml-2 animate-pulse"> <span className="inline-flex items-center gap-1 text-[10px] text-success ml-2 animate-pulse">
<Check size={10} /> Applied <Check size={10} /> Applied
@@ -86,7 +93,7 @@ function SettingsPanel() {
<h3 className="font-semibold flex items-center gap-2"> <h3 className="font-semibold flex items-center gap-2">
<Settings2 size={16} /> <Settings2 size={16} />
Overload Policy Overload Policy
<AppliedBadge visible={policyFeedback.show} /> <AppliedBadge visible={policyFeedback.show} valid={policyFeedback.valid} />
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@@ -94,7 +101,7 @@ function SettingsPanel() {
<input <input
type="number" type="number"
value={overloadPolicy.maxQueueDepth} value={overloadPolicy.maxQueueDepth}
onChange={(e) => { setOverloadPolicy({ maxQueueDepth: Number(e.target.value) }); policyFeedback.trigger(); }} onChange={(e) => { const v = Number(e.target.value); if (v >= 10) { setOverloadPolicy({ maxQueueDepth: v }); policyFeedback.trigger(true); } else { policyFeedback.trigger(false); } }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={10} min={10}
step={10} step={10}
@@ -106,7 +113,7 @@ function SettingsPanel() {
<input <input
type="number" type="number"
value={overloadPolicy.rateLimitPerCustomer} value={overloadPolicy.rateLimitPerCustomer}
onChange={(e) => { setOverloadPolicy({ rateLimitPerCustomer: Number(e.target.value) }); policyFeedback.trigger(); }} onChange={(e) => { const v = Number(e.target.value); if (v >= 100) { setOverloadPolicy({ rateLimitPerCustomer: v }); policyFeedback.trigger(true); } else { policyFeedback.trigger(false); } }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={100} min={100}
step={100} step={100}
@@ -141,8 +148,16 @@ function SettingsPanel() {
); );
} }
export function MarketPage() { const VALID_TABS = new Set(TABS.map(t => t.id));
const [activeTab, setActiveTab] = useState<MarketTab>('overview');
export function MarketPage({ initialTab, onTabChange }: { initialTab?: string | null; onTabChange?: (tab: string | null) => void }) {
const resolvedInitial = initialTab && VALID_TABS.has(initialTab as MarketTab) ? (initialTab as MarketTab) : 'overview';
const [activeTab, setActiveTab] = useState<MarketTab>(resolvedInitial);
const handleTabChange = (tab: MarketTab) => {
setActiveTab(tab);
onTabChange?.(tab === 'overview' ? null : tab);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -156,7 +171,7 @@ export function MarketPage() {
{TABS.map(tab => ( {TABS.map(tab => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => handleTabChange(tab.id)}
className={`px-4 py-2 text-sm rounded-t-lg transition-colors ${ className={`px-4 py-2 text-sm rounded-t-lg transition-colors ${
activeTab === tab.id activeTab === tab.id
? 'bg-surface-800 text-surface-100 border-b-2 border-accent' ? 'bg-surface-800 text-surface-100 border-b-2 border-accent'
+76 -11
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Scissors, Wrench, Zap, BarChart3 } from 'lucide-react'; import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Scissors, Wrench, Zap, BarChart3 } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint'; import { TutorialHint } from '@/components/game/TutorialHint';
import { ConfirmModal } from '@/components/common/ConfirmModal';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { import {
formatNumber, formatPercent, formatDuration, formatNumber, formatPercent, formatDuration,
@@ -68,6 +69,7 @@ export function ModelsPage() {
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels); const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
const completedResearch = useGameStore((s) => s.research.completedResearch); const completedResearch = useGameStore((s) => s.research.completedResearch);
const [modelsTab, setModelsTab] = useState<'overview' | 'train' | 'models' | 'benchmarks' | 'products'>('overview');
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const [expandedModel, setExpandedModel] = useState<string | null>(null); const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [expandedPipeline, setExpandedPipeline] = useState<string | null>(null); const [expandedPipeline, setExpandedPipeline] = useState<string | null>(null);
@@ -144,7 +146,29 @@ export function ModelsPage() {
Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue. Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue.
</TutorialHint> </TutorialHint>
{/* Compute Allocation */} <div className="flex gap-1 border-b border-surface-700 pb-px">
{([
{ id: 'overview' as const, label: 'Overview' },
{ id: 'train' as const, label: 'Train New' },
{ id: 'models' as const, label: `Families${families.length > 0 ? ` (${families.length})` : ''}` },
{ id: 'benchmarks' as const, label: 'Benchmarks' },
{ id: 'products' as const, label: 'Products' },
]).map(tab => (
<button
key={tab.id}
onClick={() => setModelsTab(tab.id)}
className={`px-4 py-2 text-sm rounded-t-lg transition-colors ${
modelsTab === tab.id
? 'bg-surface-800 text-surface-100 border-b-2 border-accent'
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Compute Allocation — always visible */}
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3">Compute Allocation</h3> <h3 className="font-semibold mb-3">Compute Allocation</h3>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -164,7 +188,7 @@ export function ModelsPage() {
</div> </div>
{/* Active Training Pipelines */} {/* Active Training Pipelines */}
{activePipelines.length > 0 && ( {modelsTab === 'overview' && activePipelines.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="font-semibold">Active Training</h3> <h3 className="font-semibold">Active Training</h3>
{activePipelines.map(pipeline => { {activePipelines.map(pipeline => {
@@ -259,7 +283,7 @@ export function ModelsPage() {
)} )}
{/* Active Variant Jobs */} {/* Active Variant Jobs */}
{activeVariantJobs.length > 0 && ( {modelsTab === 'overview' && activeVariantJobs.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="font-semibold">Variant Jobs</h3> <h3 className="font-semibold">Variant Jobs</h3>
{activeVariantJobs.map(job => { {activeVariantJobs.map(job => {
@@ -284,7 +308,7 @@ export function ModelsPage() {
)} )}
{/* Active Eval Jobs */} {/* Active Eval Jobs */}
{activeEvalJobs.length > 0 && ( {modelsTab === 'overview' && activeEvalJobs.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="font-semibold">Running Evaluations</h3> <h3 className="font-semibold">Running Evaluations</h3>
{activeEvalJobs.map(job => { {activeEvalJobs.map(job => {
@@ -306,7 +330,7 @@ export function ModelsPage() {
)} )}
{/* Train New Model */} {/* Train New Model */}
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> {modelsTab === 'train' && <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Train New Model</h3> <h3 className="font-semibold">Train New Model</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -382,6 +406,7 @@ export function ModelsPage() {
))} ))}
</div> </div>
</div> </div>
<p className="text-[10px] text-surface-500 mb-1">Total must equal 100% other values adjust proportionally.</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1"> <div className="grid grid-cols-2 gap-x-4 gap-y-1">
{(Object.keys(DOMAIN_LABELS) as DataDomain[]).map(domain => ( {(Object.keys(DOMAIN_LABELS) as DataDomain[]).map(domain => (
<div key={domain} className="flex items-center gap-2"> <div key={domain} className="flex items-center gap-2">
@@ -438,10 +463,10 @@ export function ModelsPage() {
)} )}
</div> </div>
</div> </div>
</div> </div>}
{/* Model Families & Trained Models */} {/* Model Families & Trained Models */}
{families.length > 0 && ( {modelsTab === 'models' && families.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="font-semibold">Model Families</h3> <h3 className="font-semibold">Model Families</h3>
{families.map(family => { {families.map(family => {
@@ -525,7 +550,7 @@ export function ModelsPage() {
)} )}
{/* Benchmark Leaderboard */} {/* Benchmark Leaderboard */}
{benchmarkResults.length > 0 && ( {modelsTab === 'benchmarks' && benchmarkResults.length > 0 && (
<BenchmarkLeaderboard <BenchmarkLeaderboard
benchmarkResults={benchmarkResults} benchmarkResults={benchmarkResults}
baseModels={baseModels} baseModels={baseModels}
@@ -533,9 +558,14 @@ export function ModelsPage() {
availableBenchmarks={availableBenchmarks} availableBenchmarks={availableBenchmarks}
/> />
)} )}
{modelsTab === 'benchmarks' && benchmarkResults.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
No benchmark results yet. Run evaluations from the Models tab.
</div>
)}
{/* Product Lines */} {/* Product Lines */}
<div className="space-y-3"> {modelsTab === 'products' && <div className="space-y-3">
<h3 className="font-semibold">Product Lines</h3> <h3 className="font-semibold">Product Lines</h3>
{productLines.map(pl => ( {productLines.map(pl => (
<div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
@@ -552,7 +582,21 @@ export function ModelsPage() {
</div> </div>
</div> </div>
))} ))}
</div>}
{/* Empty state for Models tab */}
{modelsTab === 'models' && families.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
No model families yet. Train your first model from the Train New tab.
</div> </div>
)}
{/* Empty state for Overview when nothing is active */}
{modelsTab === 'overview' && activePipelines.length === 0 && activeVariantJobs.length === 0 && activeEvalJobs.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
No active jobs. Start a training pipeline from the Train New tab.
</div>
)}
</div> </div>
); );
} }
@@ -563,10 +607,12 @@ function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: {
onDeploy: () => void; onDeploy: () => void;
onOpenSource: () => void; onOpenSource: () => void;
}) { }) {
const [confirmAction, setConfirmAction] = useState<'deploy' | 'opensource' | null>(null);
return ( return (
<> <>
{!isOpenSourced && model.isDeployed && ( {!isOpenSourced && model.isDeployed && (
<button onClick={onOpenSource} <button onClick={() => setConfirmAction('opensource')}
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs"> className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs">
<Globe size={12} /> Open Source <Globe size={12} /> Open Source
</button> </button>
@@ -574,11 +620,30 @@ function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: {
{model.isDeployed ? ( {model.isDeployed ? (
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span> <span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
) : ( ) : (
<button onClick={onDeploy} <button onClick={() => setConfirmAction('deploy')}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs"> className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs">
<Rocket size={12} /> Deploy <Rocket size={12} /> Deploy
</button> </button>
)} )}
{confirmAction === 'deploy' && (
<ConfirmModal
title="Deploy Model"
message={`Deploy "${model.name}" to production? All product lines will use this model for inference.`}
confirmLabel="Deploy"
onConfirm={() => { onDeploy(); setConfirmAction(null); }}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === 'opensource' && (
<ConfirmModal
title="Open Source Model"
message={`Open source "${model.name}"? This will make the model publicly available. Your competitors will benefit from it. This cannot be undone.`}
confirmLabel="Open Source"
danger
onConfirm={() => { onOpenSource(); setConfirmAction(null); }}
onCancel={() => setConfirmAction(null)}
/>
)}
</> </>
); );
} }
+3 -1
View File
@@ -105,10 +105,12 @@ export function ResearchPage() {
return ( return (
<div <div
key={node.id} key={node.id}
onClick={() => isAvailable && !activeResearch && handleStart(node)}
className={`rounded-xl border p-4 transition-all ${ className={`rounded-xl border p-4 transition-all ${
isCompleted ? 'border-success/50 bg-success/5 opacity-70' : isCompleted ? 'border-success/50 bg-success/5 opacity-70' :
isActive ? 'border-accent/50 bg-accent/5' : 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' 'border-surface-700 bg-surface-900 opacity-50'
}`} }`}
> >
+3
View File
@@ -83,6 +83,7 @@ export function SettingsPage() {
<div className="text-sm">Music Volume</div> <div className="text-sm">Music Volume</div>
<div className="text-xs text-surface-400">Background music level</div> <div className="text-xs text-surface-400">Background music level</div>
</div> </div>
<div className="flex items-center gap-2">
<input <input
type="range" type="range"
min={0} min={0}
@@ -91,6 +92,8 @@ export function SettingsPage() {
onChange={(e) => setMusicVolume(Number(e.target.value) / 100)} onChange={(e) => setMusicVolume(Number(e.target.value) / 100)}
className="w-32 accent-accent" className="w-32 accent-accent"
/> />
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round(settings.musicVolume * 100)}%</span>
</div>
</div> </div>
</div> </div>
+8
View File
@@ -88,6 +88,8 @@ interface Actions {
setInfraNav: (nav: InfraNav) => void; setInfraNav: (nav: InfraNav) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void; addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void; dismissNotification: (id: string) => void;
removeNotification: (id: string) => void;
clearAllNotifications: () => void;
markAllNotificationsRead: () => void; markAllNotificationsRead: () => void;
startNewGame: (companyName: string) => void; startNewGame: (companyName: string) => void;
setGameSpeed: (speed: GameSpeed) => void; setGameSpeed: (speed: GameSpeed) => void;
@@ -305,6 +307,12 @@ export const useGameStore = create<Store>()(
), ),
})), })),
removeNotification: (id) => set((s) => ({
notifications: s.notifications.filter(n => n.id !== id),
})),
clearAllNotifications: () => set({ notifications: [] }),
markAllNotificationsRead: () => set((s) => ({ markAllNotificationsRead: () => set((s) => ({
notifications: s.notifications.map(n => ({ ...n, read: true })), notifications: s.notifications.map(n => ({ ...n, read: true })),
})), })),