feat(ui): complete Task 4 UI redesign — all sub-tasks 1-10
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 8m47s

- Implemented complete design system with 40+ CSS custom properties
- Created 9 UI primitives (GameCard, SectionHeader, StatRow, ManaBar, ElementBadge, ValueDisplay, ActionButton, SkillRow, TooltipInfo)
- Redesigned all tabs: Spire, Skills, Stats, Equipment, Crafting, Attunements, Golemancy, Spells, Loot, Achievements, Lab, Debug
- Added toast notification system (GameToast) with success/warning/error/info types
- Added confirmation dialogs for destructive actions
- Removed all dev artifacts and component name labels
- Added empty states to all tabs
- Replaced emoji icons with Lucide React icons
- Added enchantPower placeholder to StatsTab and EquipmentTab
- Mobile audit passed at 375px viewport
- Build passes with 0 errors, lint passes with 0 errors

Sub-tasks completed:
- ST1: Design System Implementation
- ST2: Global Layout & Header
- ST3: Left Panel (Mana Display & Action Area)
- ST4: Skills Tab
- ST5: Spire Tab & Spire Mode UI
- ST6: Stats Tab
- ST7: Equipment & Crafting Tabs
- ST8: Attunements Tab
- ST9: Remaining Tabs
- ST10: Toast System & Confirmation Dialogs

Documentation: 15+ files in docs/task4/
This commit is contained in:
Refactoring Agent
2026-04-28 11:38:45 +02:00
parent 3c29c1c834
commit 47c71e6f54
61 changed files with 6892 additions and 1842 deletions
+182 -107
View File
@@ -14,6 +14,9 @@ import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { StudyProgress } from './StudyProgress';
import { UpgradeDialog } from './UpgradeDialog';
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
import { useGameToast } from '@/components/game/GameToast';
import { ELEMENTS } from '@/lib/game/constants';
import { ChevronDown, ChevronRight } from 'lucide-react';
export interface SkillsTabProps {
@@ -53,10 +56,12 @@ function hasMilestoneUpgrade(
}
export function SkillsTab({ store }: SkillsTabProps) {
const showToast = useGameToast();
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [cancelStudyConfirm, setCancelStudyConfirm] = useState<{ skillId: string; skillName: string } | null>(null);
const studySpeedMult = getStudySpeedMultiplier(store.skills);
const upgradeEffects = getUnifiedEffects(store);
@@ -73,15 +78,15 @@ export function SkillsTab({ store }: SkillsTabProps) {
return newSet;
});
};
// Get upgrade choices for dialog
const getUpgradeChoices = () => {
if (!upgradeDialogSkill) return { available: [] as SkillUpgradeChoice[], selected: [] as string[] };
return store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
};
const { available, selected: alreadySelected } = getUpgradeChoices();
// Toggle selection
const toggleUpgrade = (upgradeId: string) => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
@@ -91,7 +96,7 @@ export function SkillsTab({ store }: SkillsTabProps) {
setPendingUpgradeSelections([...currentSelections, upgradeId]);
}
};
// Commit selections and close
const handleConfirm = () => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
@@ -101,13 +106,47 @@ export function SkillsTab({ store }: SkillsTabProps) {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
// Cancel and close
const handleCancel = () => {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
// Handle study start with toast
const handleStartStudying = (skillId: string) => {
const skillDef = SKILLS_DEF[skillId.includes('_t') ? skillId.split('_t')[0] : skillId];
store.startStudyingSkill(skillId);
showToast('info', 'Study Started', `Studying ${skillDef?.name || 'skill'}...`);
};
// Handle parallel study start with toast
const handleParallelStudy = (skillId: string) => {
const skillDef = SKILLS_DEF[skillId.includes('_t') ? skillId.split('_t')[0] : skillId];
store.startParallelStudySkill(skillId);
showToast('info', 'Parallel Study Started', `Studying ${skillDef?.name || 'skill'} in parallel (50% speed)...`);
};
// Handle study cancel with confirmation
const handleCancelStudy = () => {
const currentTarget = store.currentStudyTarget;
if (currentTarget?.type === 'skill') {
const skillDef = SKILLS_DEF[currentTarget.id.includes('_t') ? currentTarget.id.split('_t')[0] : currentTarget.id];
setCancelStudyConfirm({
skillId: currentTarget.id,
skillName: skillDef?.name || 'Unknown Skill'
});
}
};
const confirmCancelStudy = () => {
if (cancelStudyConfirm) {
store.cancelStudy();
showToast('warning', 'Study Cancelled', `${cancelStudyConfirm.skillName} study cancelled. Progress will be partially saved based on your Knowledge Retention skill.`);
setCancelStudyConfirm(null);
}
};
return (
<div className="space-y-4">
{/* Upgrade Selection Dialog */}
@@ -128,7 +167,20 @@ export function SkillsTab({ store }: SkillsTabProps) {
}
}}
/>
{/* Cancel Study Confirmation Dialog */}
{cancelStudyConfirm && (
<ConfirmDialog
open={!!cancelStudyConfirm}
onOpenChange={() => setCancelStudyConfirm(null)}
title="Cancel Studying?"
description={`Cancel studying ${cancelStudyConfirm.skillName}? Progress will be partially saved based on your Knowledge Retention skill.`}
variant="warning"
confirmText="Cancel Study"
onConfirm={confirmCancelStudy}
/>
)}
{/* Current Study Progress */}
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
<Card className="bg-gray-900/80 border-purple-600/50">
@@ -137,24 +189,24 @@ export function SkillsTab({ store }: SkillsTabProps) {
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
cancelStudy={handleCancelStudy}
/>
</CardContent>
</Card>
)}
{/* Get available skill categories based on attunements */}
{(() => {
const availableCategories = getAvailableSkillCategories(store.attunements || {});
return SKILL_CATEGORIES
.filter(cat => availableCategories.includes(cat.id))
.map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
const isCollapsed = collapsedCategories.has(cat.id);
return (
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2 cursor-pointer" onClick={() => toggleCategory(cat.id)}>
@@ -174,18 +226,18 @@ export function SkillsTab({ store }: SkillsTabProps) {
const currentTier = store.skillTiers?.[id] || 1;
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
const tierMultiplier = getTierMultiplier(tieredSkillId);
// Get the actual level from the tiered skill
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
const maxed = level >= def.max;
// Check if studying this skill
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
// Get tier name for display
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
const skillDisplayName = tierDef?.name || def.name;
// Check prerequisites
let prereqMet = true;
if (def.req) {
@@ -196,27 +248,27 @@ export function SkillsTab({ store }: SkillsTabProps) {
}
}
}
// Apply skill modifiers
const costMult = getStudyCostMultiplier(store.skills);
const speedMult = getStudySpeedMultiplier(store.skills);
const studyEffects = getUnifiedEffects(store);
const effectiveSpeedMult = speedMult * studyEffects.studySpeedMultiplier;
// Study time scales with tier
const tierStudyTime = def.studyTime * currentTier;
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
// Cost scales with tier
const baseCost = def.base * (level + 1) * currentTier;
const cost = Math.floor(baseCost * costMult);
// Additional cost (element mana)
const additionalCost = def.cost;
// Can start studying?
let canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
// Check additional cost (element mana)
if (def.cost && def.cost.type === 'element') {
const element = store.elements[def.cost.element];
@@ -224,19 +276,22 @@ export function SkillsTab({ store }: SkillsTabProps) {
canStudy = false;
}
}
// Check for milestone upgrades
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level, store.skillTiers || {}, store.skillUpgrades);
// Check for tier up
const nextTierSkill = getNextTierSkill(tieredSkillId);
const canTierUp = maxed && nextTierSkill;
// Get selected upgrades
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
// Check if insufficient mana for toast
const hasInsufficientMana = !isStudying && !maxed && store.rawMana < cost;
return (
<div
key={id}
@@ -284,95 +339,115 @@ export function SkillsTab({ store }: SkillsTabProps) {
)}
</span>
</div>
{milestoneInfo && (
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
{hasInsufficientMana && (
<div className="text-xs text-red-400 mt-1">
Insufficient mana! Need {fmt(cost)} mana to study.
</div>
)}
{milestoneInfo && (
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
</div>
)}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
</div>
) : milestoneInfo ? (
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => store.startStudyingSkill(tieredSkillId)}
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Study ({fmt(cost)}{additionalCost && additionalCost.type === 'element' && ` + ${additionalCost.amount} ${ELEMENTS[additionalCost.element]?.sym}`}
Choose Upgrades
</Button>
{/* Parallel Study button */}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => store.startParallelStudySkill(tieredSkillId)}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => {
if (store.rawMana < cost) {
const deficit = cost - store.rawMana;
showToast('error', 'Insufficient Mana', `Need ${fmt(deficit)} more mana to study ${skillDisplayName}`);
return;
}
handleStartStudying(tieredSkillId);
}}
>
Study ({fmt(cost)}{additionalCost && additionalCost.type === 'element' && ` + ${additionalCost.amount} ${ELEMENTS[additionalCost.element]?.sym}`}
</Button>
{/* Parallel Study button */}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => {
if (store.rawMana < cost) {
const deficit = cost - store.rawMana;
showToast('error', 'Insufficient Mana', `Need ${fmt(deficit)} more mana for parallel study`);
return;
}
handleParallelStudy(tieredSkillId);
}}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
</CardContent>
)}