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
@@ -1,26 +1,19 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useState } from 'react';
import { ActionButton } from '@/components/ui/action-button';
import { GameCard } from '@/components/ui/game-card';
import { SectionHeader } from '@/components/ui/section-header';
import { StatRow } from '@/components/ui/stat-row';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Trash2 } from 'lucide-react';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress, EquipmentSlot } from '@/lib/game/types';
import { fmt, type GameStore } from '@/lib/game/store';
// Slot display names
const SLOT_NAMES: Record<EquipmentSlot, string> = {
mainHand: 'Main Hand',
offHand: 'Off Hand',
head: 'Head',
body: 'Body',
hands: 'Hands',
feet: 'Feet',
accessory1: 'Accessory 1',
accessory2: 'Accessory 2',
};
import { useGameToast } from '@/components/game/GameToast';
export interface EnchantmentPreparerProps {
store: GameStore;
@@ -33,6 +26,7 @@ export function EnchantmentPreparer({
selectedEquipmentInstance,
setSelectedEquipmentInstance,
}: EnchantmentPreparerProps) {
const showToast = useGameToast();
const equippedInstances = store.equippedInstances;
const equipmentInstances = store.equipmentInstances;
const preparationProgress = store.preparationProgress;
@@ -49,170 +43,263 @@ export function EnchantmentPreparer({
instance: equipmentInstances[instanceId!],
}));
// Confirm dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const handleStartPreparation = () => {
if (!selectedEquipmentInstance) return;
const instance = equipmentInstances[selectedEquipmentInstance];
if (!instance) return;
// If item has existing enchantments, show confirm dialog (bug #8)
if (instance.enchantments.length > 0) {
setShowConfirmDialog(true);
} else {
startPreparingWithToast(selectedEquipmentInstance);
}
};
const startPreparingWithToast = (instanceId: string) => {
const instance = equipmentInstances[instanceId];
startPreparing(instanceId);
if (instance) {
showToast('info', 'Preparation Started', `Preparing ${instance.name} for enchantment...`);
}
};
const confirmPreparation = () => {
if (selectedEquipmentInstance) {
startPreparingWithToast(selectedEquipmentInstance);
setShowConfirmDialog(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Select Equipment to Prepare</CardTitle>
</CardHeader>
<CardContent>
{preparationProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
</div>
<Progress value={(preparationProgress.progress / preparationProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
<Button size="sm" variant="outline" onClick={cancelPreparation}>Cancel</Button>
<GameCard variant="default">
<SectionHeader title="Select Equipment to Prepare" />
{preparationProgress ? (
<div className="space-y-3">
<div className="text-sm text-[var(--text-secondary)]">
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{equippedItems.map(({ slot, instance }) => {
const hasEnchantments = instance.enchantments.length > 0;
const isReady = instance.tags?.includes('Ready for Enchantment');
return (
<div
key={instance.instanceId}
className={`p-3 rounded border cursor-pointer transition-all ${
selectedEquipmentInstance === instance.instanceId
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
} ${hasEnchantments ? 'border-l-4 border-l-red-600' : ''} ${isReady ? 'border-l-4 border-l-green-600' : ''}`}
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
>
<div className="flex justify-between">
<div>
<div className="font-semibold">{instance.name}</div>
<div className="text-xs text-gray-400">{SLOT_NAMES[slot]}</div>
{hasEnchantments && (
<div className="text-xs text-red-400 mt-1">
{instance.enchantments.length} enchantments - Preparation will remove them
</div>
)}
{isReady && (
<div className="text-xs text-green-400 mt-1">
Ready for Enchantment
</div>
)}
</div>
<div className="text-right text-sm">
<div className="text-green-400">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
<div className="text-xs text-gray-400">{instance.enchantments.length} enchants</div>
</div>
</div>
</div>
);
})}
{equippedItems.length === 0 && (
<div className="text-center text-gray-400 py-4">No equipped items</div>
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Preparation Details */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Preparation Details</CardTitle>
</CardHeader>
<CardContent>
{!selectedEquipmentInstance ? (
<div className="text-center text-gray-400 py-8">
Select equipment to prepare
<div className="h-3 bg-[var(--bg-sunken)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-warning)] transition-all duration-300"
style={{ width: `${(preparationProgress.progress / preparationProgress.required) * 100}%` }}
/>
</div>
) : preparationProgress ? (
<div className="text-gray-400">Preparation in progress...</div>
) : (
(() => {
const instance = equipmentInstances[selectedEquipmentInstance];
if (!instance) return null;
const hasEnchantments = instance.enchantments.length > 0;
const isReady = instance.tags?.includes('Ready for Enchantment');
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
const manaCost = instance.totalCapacity * 10;
// Calculate disenchant recovery
const recoveryRate = 0.1; // Base recovery rate (disenchanting skill removed)
const totalRecoverable = instance.enchantments.reduce(
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
0
);
return (
<div className="space-y-4">
<div className="text-lg font-semibold">{instance.name}</div>
<Separator className="bg-gray-700" />
{/* Show warning if item has enchantments */}
{hasEnchantments && !isReady && (
<div className="p-3 rounded border border-red-600/50 bg-red-900/20">
<div className="text-sm font-semibold text-red-400"> Equipment has enchantments</div>
<div className="text-xs text-gray-400 mt-1">
Preparation will remove all existing enchantments and recover some mana.
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-gray-400">Recoverable Mana:</span>
<span className="text-green-400">{fmt(totalRecoverable)}</span>
</div>
</div>
)}
{/* Show ready status */}
{isReady && (
<div className="p-3 rounded border border-green-600/50 bg-green-900/20">
<div className="text-sm font-semibold text-green-400"> Ready for Enchantment</div>
<div className="text-xs text-gray-400 mt-1">
This item has been prepared and is ready for enchantment application.
</div>
</div>
)}
<div className="space-y-2 text-sm">
<div className="flex justify-between text-xs text-[var(--text-muted)]">
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
<ActionButton size="sm" variant="outline" onClick={() => {
cancelPreparation();
showToast('warning', 'Preparation Cancelled', 'Equipment preparation was cancelled.');
}}>Cancel</ActionButton>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{equippedItems.map(({ slot, instance }) => {
const hasEnchantments = instance.enchantments.length > 0;
const isReady = instance.tags?.includes('Ready for Enchantment');
return (
<div
key={instance.instanceId}
className={`p-3 rounded border cursor-pointer transition-all
${selectedEquipmentInstance === instance.instanceId
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
}
${hasEnchantments ? 'border-l-4 border-l-[var(--color-danger)]' : ''}
${isReady ? 'border-l-4 border-l-[var(--color-success)]' : ''}
`}
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
role="button"
tabIndex={0}
aria-label={`${instance.name}${hasEnchantments ? ' (has enchantments)' : ''}${isReady ? ' (ready for enchantment)' : ''}`}
>
<div className="flex justify-between">
<span className="text-gray-400">Capacity:</span>
<span>{instance.usedCapacity}/{instance.totalCapacity}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Prep Time:</span>
<span>{prepTime}h</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Mana Cost:</span>
<span className={rawMana < manaCost ? 'text-red-400' : 'text-green-400'}>
{fmt(manaCost)}
</span>
<div>
<div className="font-semibold text-[var(--text-primary)]">{instance.name}</div>
<div className="text-xs text-[var(--text-muted)]">{slot}</div>
{hasEnchantments && (
<div className="text-xs text-[var(--color-danger)] mt-1">
<AlertTriangle size={12} className="inline mr-1" />
{instance.enchantments.length} enchantments - Preparation will remove them
</div>
)}
{isReady && (
<div className="text-xs text-[var(--color-success)] mt-1">
<CheckCircle size={12} className="inline mr-1" />
Ready for Enchantment
</div>
)}
</div>
<div className="text-right text-sm">
<div className="text-[var(--color-success)]">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
<div className="text-xs text-[var(--text-muted)]">{instance.enchantments.length} enchants</div>
{/* Requirement: Visual badge for 'Ready for Enchantment' */}
{isReady && (
<Badge className="mt-1 bg-[var(--color-success)]/20 text-[var(--color-success)] border-[var(--color-success)]/40">
<CheckCircle size={10} className="mr-1" />
Ready
</Badge>
)}
</div>
</div>
</div>
);
})}
{equippedItems.length === 0 && (
<div className="text-center text-[var(--text-muted)] py-4">No equipped items</div>
)}
</div>
</ScrollArea>
)}
</GameCard>
<Button
className="w-full"
disabled={rawMana < manaCost || isReady}
onClick={() => startPreparing(selectedEquipmentInstance)}
>
{hasEnchantments ? (
<>
<Trash2 className="w-4 h-4 mr-2" />
Start Preparation this will remove existing enchantments ({prepTime}h, {fmt(manaCost)} mana)
</>
) : (
<>Start Preparation ({prepTime}h, {fmt(manaCost)} mana)</>
)}
</Button>
{/* Preparation Details */}
<GameCard variant="default">
<SectionHeader title="Preparation Details" />
{!selectedEquipmentInstance ? (
<div className="text-center text-[var(--text-muted)] py-8">
Select equipment to prepare
</div>
) : preparationProgress ? (
<div className="text-[var(--text-secondary)]">Preparation in progress...</div>
) : (
(() => {
const instance = equipmentInstances[selectedEquipmentInstance];
if (!instance) return null;
const hasEnchantments = instance.enchantments.length > 0;
const isReady = instance.tags?.includes('Ready for Enchantment');
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
const manaCost = instance.totalCapacity * 10;
// Calculate disenchant recovery
const recoveryRate = 0.1; // Base recovery rate
const totalRecoverable = instance.enchantments.reduce(
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
0
);
return (
<div className="space-y-4">
<div className="text-lg font-semibold text-[var(--text-primary)]">{instance.name}</div>
<Separator className="bg-[var(--border-subtle)]" />
{/* Show warning if item has enchantments - Requirement: button reads "Prepare — removes existing enchantments" */}
{hasEnchantments && !isReady && (
<div className="p-3 rounded border border-[var(--color-danger)]/50 bg-[var(--color-danger)]/10">
<div className="text-sm font-semibold text-[var(--color-danger)]">
<AlertTriangle size={14} className="inline mr-1" />
Equipment has enchantments
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">
Preparation will remove all existing enchantments and recover some mana.
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-[var(--text-muted)]">Recoverable Mana:</span>
<span className="text-[var(--color-success)]">{fmt(totalRecoverable)}</span>
</div>
</div>
)}
{/* Show ready status */}
{isReady && (
<div className="p-3 rounded border border-[var(--color-success)]/50 bg-[var(--color-success)]/10">
<div className="text-sm font-semibold text-[var(--color-success)]">
<CheckCircle size={14} className="inline mr-1" />
Ready for Enchantment
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">
This item has been prepared and is ready for enchantment application.
</div>
</div>
)}
<div className="space-y-2 text-sm">
<StatRow
label="Capacity:"
value={`${instance.usedCapacity}/${instance.totalCapacity}`}
highlight="default"
/>
<StatRow
label="Prep Time:"
value={`${prepTime}h`}
highlight="default"
/>
<StatRow
label="Mana Cost:"
value={
<span className={rawMana < manaCost ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
{fmt(manaCost)}
</span>
}
highlight={rawMana < manaCost ? 'danger' : 'success'}
/>
</div>
);
})()
)}
</CardContent>
</Card>
{/* Requirement (bug #8): Confirm dialog before proceeding if item has enchantments */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogTrigger asChild>
<ActionButton
className="w-full"
disabled={rawMana < manaCost || isReady}
onClick={handleStartPreparation}
>
{hasEnchantments ? (
<>
<Trash2 size={16} className="mr-2" />
Prepare removes existing enchantments ({prepTime}h, {fmt(manaCost)} mana)
</>
) : (
<>Start Preparation ({prepTime}h, {fmt(manaCost)} mana)</>
)}
</ActionButton>
</AlertDialogTrigger>
<AlertDialogContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
<AlertDialogHeader>
<AlertDialogTitle className="text-[var(--color-danger)]">
<AlertTriangle className="inline mr-2" size={18} />
Confirm Preparation
</AlertDialogTitle>
<AlertDialogDescription className="text-[var(--text-secondary)]">
This equipment has {instance.enchantments.length} existing enchantment(s). Preparation will
<strong className="text-[var(--color-danger)]"> permanently remove</strong> all existing enchantments
and recover approximately <strong className="text-[var(--color-success)]">{fmt(totalRecoverable)} mana</strong>.
<div className="mt-2 p-2 bg-[var(--bg-sunken)]/50 rounded text-xs">
Equipment: {instance.name}<br />
Enchantments to remove: {instance.enchantments.length}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
onClick={() => setShowConfirmDialog(false)}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-[var(--color-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
onClick={confirmPreparation}
>
Yes, Remove Enchantments & Prepare
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
})()
)}
</GameCard>
</div>
);
}
EnchantmentPreparer.displayName = "EnchantmentPreparer";
EnchantmentPreparer.displayName = 'EnchantmentPreparer';