* Fix hand card rotation direction Cards now fan outward correctly instead of curling inward * Update StateRenderer to require MatchScene type for type safety - Change constructor parameter from Phaser.Scene to MatchScene - Update scene property type to MatchScene - Add import for MatchScene type - Update JSDoc example to reflect type-safe constructor * Defer Board creation to StateRenderer for correct rules config - Make board property nullable (Board | null instead of Board?) - Remove Board and createBoard imports (now handled by StateRenderer) - Update setupBoard() to skip Board creation - Add setBoard() method for StateRenderer to call - Update clearBoard() to use null instead of undefined - Add JSDoc explaining why Board creation is deferred * Create Board in StateRenderer with correct layout options - Add Board and createBoard imports - Add board property to StateRenderer - Create Board in render() on first call with correct rules_config - Add debug logging for Board creation and zone creation - Update clear() to destroy Board when clearing - Board now created after we have rules_config from first state * Add fatal error handling with toast notification and auto-redirect - Add 'fatal-error' event to GameBridgeEvents type - Import and initialize useToast in GamePage - Listen for 'fatal-error' event from Phaser - Show error toast that persists until redirect - Show full-screen fatal error overlay with countdown - Auto-redirect to /play after 3 seconds - Update StateRenderer to emit 'fatal-error' when Board creation fails * Gate debug logging with DEV flag - Add DEBUG_RENDERER constant gated by import.meta.env.DEV - Update all console.log statements in StateRenderer to only log in development - Keep console.error and console.warn as they are (always show errors) - Debug logs now only appear during development, not in production * Fix code audit issues - add missing imports and improve error UX Critical fixes: - Add missing gameBridge import to StateRenderer (fixes runtime error in fatal error handler) - Add missing Board type import to MatchScene (fixes TypeScript compilation error) UX improvements: - Replace fatal error auto-redirect with manual 'Return to Menu' button - Add toast notification when resignation fails - Give users unlimited time to read fatal errors before returning Addresses issues found in frontend code audit: - errors.missing-import (StateRenderer.ts:166) - errors.missing-type-import (MatchScene.ts:84) - errors.catch-only-console (GamePage.vue:145) - architecture.missing-fatal-error-handling (GamePage.vue:261) * Add CONTRIBUTING policy and fix pre-existing lint/test errors - Add CONTRIBUTING.md with strict policy: never use --no-verify without approval - Add comprehensive testing documentation (TESTING.md, VISUAL-TEST-GUIDE.md) - Add test-prize-fix.md quick test checklist and verify-fix.sh script Lint fixes (enables pre-commit hooks): - Remove unused imports in 9 files - Fix unused variables (underscore convention) - Replace 'as any' type assertions with proper VisibleGameState types - Add missing CARD_WIDTH_MEDIUM import in layout.spec.ts - All ESLint errors now resolved (only acceptable warnings remain) Test fixes (all 1000 tests now passing): - Fix layout.spec.ts: Add missing CARD_WIDTH_MEDIUM import - Fix PlayPage.spec.ts: Update test to use actual hardcoded UUIDs - Fix useAuth.spec.ts: Mock API profile fetch in initialization tests - Fix PhaserGame.spec.ts: Add scenes export to mock and update createGame call expectations This ensures pre-commit hooks work properly going forward and prevents bypassing TypeScript/lint checks that catch errors early. * Add comprehensive test coverage improvement plan - Create PROJECT_PLAN_TEST_COVERAGE.json with 25 structured tasks - Create TEST_COVERAGE_PLAN.md with executive summary and roadmap - Plan addresses critical gaps: game engine (0%), WebSocket (27%) - 6-week roadmap to reach 85% coverage from current 63% - Target: Phase 1 (weeks 1-3) - critical game engine and network tests - Includes quick wins, production blockers, and success metrics Based on coverage analysis showing: - Strong: Composables (84%), Components (90%), Stores (88%) - Critical gaps: Phaser game engine (~5,500 untested lines) - High priority: WebSocket/multiplayer reliability See TEST_COVERAGE_PLAN.md for overview and week-by-week breakdown. * Add coverage tooling and ignore coverage directory - Add @vitest/coverage-v8 package for coverage analysis - Add coverage/ directory to .gitignore - Used during test coverage analysis for PROJECT_PLAN_TEST_COVERAGE.json
712 lines
17 KiB
Vue
712 lines
17 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Attack menu component - UI for selecting which attack to use.
|
|
*
|
|
* This component shows when the user taps 'Attack' or taps the active Pokemon
|
|
* during the attack phase. It displays all available attacks for the current
|
|
* active Pokemon with their energy costs, damage, and effects.
|
|
*
|
|
* Key features:
|
|
* - Lists all attacks with energy cost, damage, and effect description
|
|
* - Validates energy requirements and disables attacks without enough energy
|
|
* - Checks status conditions (paralyzed) that prevent attacks
|
|
* - Shows reason tooltips for disabled attacks
|
|
* - Handles target selection for attacks that require targeting
|
|
* - Dispatches attack action to server on selection
|
|
*
|
|
* Visual design:
|
|
* - Centered modal or bottom sheet on mobile
|
|
* - Card-like attack entries
|
|
* - Energy cost icons in correct colors
|
|
* - Disabled attacks grayed out with reason tooltip
|
|
* - Smooth open/close animation
|
|
*/
|
|
import { computed, inject, type ComputedRef } from 'vue'
|
|
import type { VisibleGameState, Attack, CardInstance, CardDefinition, EnergyType } from '@/types'
|
|
import { useGameActions } from '@/composables/useGameActions'
|
|
import { useGameStore } from '@/stores/game'
|
|
import { getEnergyColor } from '@/utils/energyColors'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Props and Emits
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Props {
|
|
/** Whether the attack menu is open */
|
|
show: boolean
|
|
}
|
|
|
|
// Props are used in template via direct reference (show, not props.show)
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const props = defineProps<Props>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
attackSelected: [attackIndex: number]
|
|
}>()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Composables and State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Inject game state from GameOverlay parent
|
|
const gameState = inject<ComputedRef<VisibleGameState | null>>('gameState')
|
|
const isMyTurn = inject<ComputedRef<boolean>>('isMyTurn')
|
|
|
|
// Guard against missing injections
|
|
if (!gameState || !isMyTurn) {
|
|
throw new Error('AttackMenu must be used within GameOverlay')
|
|
}
|
|
|
|
const gameStore = useGameStore()
|
|
const { attack, isPending } = useGameActions()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Computed - Active Pokemon and Attacks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The active Pokemon instance.
|
|
*/
|
|
const activePokemon = computed<CardInstance | null>(() => {
|
|
return gameStore.myActive
|
|
})
|
|
|
|
/**
|
|
* The active Pokemon's card definition.
|
|
*/
|
|
const activePokemonDefinition = computed<CardDefinition | null>(() => {
|
|
if (!activePokemon.value || !gameState.value) return null
|
|
return gameStore.lookupCard(activePokemon.value.definition_id)
|
|
})
|
|
|
|
/**
|
|
* Available attacks for the active Pokemon.
|
|
*/
|
|
const attacks = computed<Attack[]>(() => {
|
|
return activePokemonDefinition.value?.attacks ?? []
|
|
})
|
|
|
|
/**
|
|
* Energy attached to the active Pokemon.
|
|
*/
|
|
const attachedEnergy = computed<EnergyType[]>(() => {
|
|
if (!activePokemon.value || !gameState.value) return []
|
|
|
|
// Get energy types from attached energy cards
|
|
return activePokemon.value.attached_energy.map(energyCard => {
|
|
const energyDef = gameStore.lookupCard(energyCard.definition_id)
|
|
return energyDef?.energy_type ?? 'colorless'
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Whether the active Pokemon has a status condition preventing attacks.
|
|
*/
|
|
const hasStatusPreventingAttack = computed<boolean>(() => {
|
|
if (!activePokemon.value) return false
|
|
|
|
// Paralyzed prevents all attacks
|
|
return activePokemon.value.status_conditions.includes('paralyzed')
|
|
})
|
|
|
|
/**
|
|
* Reason why attacks are disabled (if any).
|
|
*/
|
|
const disabledReason = computed<string | null>(() => {
|
|
if (!isMyTurn.value) {
|
|
return 'Not your turn'
|
|
}
|
|
|
|
if (hasStatusPreventingAttack.value) {
|
|
return 'Active Pokemon is paralyzed'
|
|
}
|
|
|
|
return null
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Attack Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check if an attack has enough energy to be used.
|
|
*
|
|
* @param attack - The attack to check
|
|
* @returns True if the attack can be used
|
|
*/
|
|
function hasEnoughEnergy(attack: Attack): boolean {
|
|
const cost = attack.cost
|
|
const available = [...attachedEnergy.value]
|
|
|
|
// Check if we have enough of each energy type
|
|
for (const requiredType of cost) {
|
|
if (requiredType === 'colorless') {
|
|
// Colorless can be satisfied by any energy type
|
|
if (available.length === 0) return false
|
|
available.pop() // Remove any energy
|
|
} else {
|
|
// Specific type required
|
|
const index = available.indexOf(requiredType)
|
|
if (index === -1) return false
|
|
available.splice(index, 1) // Remove that specific energy
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Check if an attack is disabled.
|
|
*
|
|
* @param attack - The attack to check
|
|
* @returns True if the attack is disabled
|
|
*/
|
|
function isAttackDisabled(attack: Attack): boolean {
|
|
// Global disable reason (status, not your turn)
|
|
if (disabledReason.value) return true
|
|
|
|
// Not enough energy
|
|
if (!hasEnoughEnergy(attack)) return true
|
|
|
|
// Action is pending
|
|
if (isPending.value) return true
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Get the reason why an attack is disabled.
|
|
*
|
|
* @param attack - The attack to check
|
|
* @returns Disabled reason string, or null if enabled
|
|
*/
|
|
function getDisabledReason(attack: Attack): string | null {
|
|
// Check global disable reason first
|
|
if (disabledReason.value) return disabledReason.value
|
|
|
|
// Check energy requirement
|
|
if (!hasEnoughEnergy(attack)) {
|
|
return 'Not enough energy'
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Action Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handle attack selection.
|
|
*
|
|
* Dispatches the attack action to the server.
|
|
*
|
|
* @param attackIndex - Index of the selected attack
|
|
*/
|
|
async function handleAttackSelect(attackIndex: number): Promise<void> {
|
|
if (isAttackDisabled(attacks.value[attackIndex])) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Emit to parent first so it can handle any necessary UI state
|
|
emit('attackSelected', attackIndex)
|
|
|
|
// TODO: Check if attack requires targeting
|
|
// For now, assume no targeting required (most common case)
|
|
await attack(attackIndex)
|
|
|
|
// Close menu after successful attack
|
|
emit('close')
|
|
} catch (error) {
|
|
console.error('Failed to perform attack:', error)
|
|
// Error notification is handled by socket handler
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle cancel button click.
|
|
*/
|
|
function handleCancel(): void {
|
|
emit('close')
|
|
}
|
|
|
|
/**
|
|
* Handle backdrop click to close menu.
|
|
*/
|
|
function handleBackdropClick(event: MouseEvent): void {
|
|
if (event.target === event.currentTarget) {
|
|
emit('close')
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Energy Cost Display Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get the color for an energy type.
|
|
*
|
|
* @param energyType - The energy type
|
|
* @returns Hex color string
|
|
*/
|
|
function getEnergyColorValue(energyType: EnergyType): string {
|
|
return getEnergyColor(energyType)
|
|
}
|
|
|
|
/**
|
|
* Get energy type initial for display.
|
|
*
|
|
* @param energyType - The energy type
|
|
* @returns Single character initial
|
|
*/
|
|
function getEnergyInitial(energyType: EnergyType): string {
|
|
const initials: Record<EnergyType, string> = {
|
|
colorless: 'C',
|
|
darkness: 'D',
|
|
dragon: 'N',
|
|
fighting: 'F',
|
|
fire: 'R',
|
|
grass: 'G',
|
|
lightning: 'L',
|
|
metal: 'M',
|
|
psychic: 'P',
|
|
water: 'W',
|
|
}
|
|
return initials[energyType]
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Transition name="attack-menu-fade">
|
|
<div
|
|
v-if="show"
|
|
class="attack-menu-backdrop"
|
|
data-testid="attack-menu-backdrop"
|
|
@click="handleBackdropClick"
|
|
>
|
|
<div
|
|
class="attack-menu"
|
|
data-testid="attack-menu"
|
|
@click.stop
|
|
>
|
|
<!-- Header -->
|
|
<div class="attack-menu__header">
|
|
<h2 class="attack-menu__title">
|
|
Select Attack
|
|
</h2>
|
|
<button
|
|
class="attack-menu__close"
|
|
aria-label="Close"
|
|
data-testid="close-button"
|
|
@click="handleCancel"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Active Pokemon Name -->
|
|
<div
|
|
v-if="activePokemonDefinition"
|
|
class="attack-menu__pokemon"
|
|
>
|
|
{{ activePokemonDefinition.name }}
|
|
</div>
|
|
|
|
<!-- Global Disable Message -->
|
|
<div
|
|
v-if="disabledReason"
|
|
class="attack-menu__warning"
|
|
data-testid="disabled-warning"
|
|
>
|
|
{{ disabledReason }}
|
|
</div>
|
|
|
|
<!-- Attack List -->
|
|
<div
|
|
v-if="attacks.length > 0"
|
|
class="attack-list"
|
|
data-testid="attack-list"
|
|
>
|
|
<button
|
|
v-for="(atk, index) in attacks"
|
|
:key="index"
|
|
:disabled="isAttackDisabled(atk)"
|
|
:class="{
|
|
'attack-entry': true,
|
|
'attack-entry--disabled': isAttackDisabled(atk),
|
|
}"
|
|
:data-testid="`attack-${index}`"
|
|
@click="handleAttackSelect(index)"
|
|
>
|
|
<!-- Attack Name and Damage -->
|
|
<div class="attack-entry__header">
|
|
<span class="attack-entry__name">{{ atk.name }}</span>
|
|
<span class="attack-entry__damage">
|
|
{{ atk.damage_display || atk.damage }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Energy Cost -->
|
|
<div class="attack-entry__cost">
|
|
<div
|
|
v-for="(energyType, costIndex) in atk.cost"
|
|
:key="costIndex"
|
|
class="energy-icon"
|
|
:style="{ backgroundColor: getEnergyColorValue(energyType) }"
|
|
:title="energyType"
|
|
>
|
|
{{ getEnergyInitial(energyType) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Effect Description -->
|
|
<div
|
|
v-if="atk.effect_description"
|
|
class="attack-entry__effect"
|
|
>
|
|
{{ atk.effect_description }}
|
|
</div>
|
|
|
|
<!-- Disabled Reason Tooltip -->
|
|
<div
|
|
v-if="isAttackDisabled(atk) && getDisabledReason(atk)"
|
|
class="attack-entry__tooltip"
|
|
data-testid="disabled-reason"
|
|
>
|
|
{{ getDisabledReason(atk) }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- No Attacks Message -->
|
|
<div
|
|
v-else
|
|
class="attack-menu__empty"
|
|
>
|
|
No attacks available
|
|
</div>
|
|
|
|
<!-- Cancel Button -->
|
|
<div class="attack-menu__footer">
|
|
<button
|
|
class="cancel-button"
|
|
data-testid="cancel-button"
|
|
@click="handleCancel"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/**
|
|
* Backdrop - full screen overlay with semi-transparent background.
|
|
*/
|
|
.attack-menu-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 40; /* Above game overlay */
|
|
padding: 1rem;
|
|
}
|
|
|
|
/**
|
|
* Attack menu container.
|
|
*/
|
|
.attack-menu {
|
|
background: rgb(31, 41, 55); /* bg-gray-800 */
|
|
border-radius: 0.75rem;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
max-width: 28rem; /* max-w-md */
|
|
width: 100%;
|
|
max-height: 90vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/**
|
|
* Header with title and close button.
|
|
*/
|
|
.attack-menu__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 1px solid rgba(75, 85, 99, 0.5); /* border-gray-600 */
|
|
}
|
|
|
|
.attack-menu__title {
|
|
font-size: 1.125rem; /* text-lg */
|
|
font-weight: 700; /* font-bold */
|
|
color: rgb(249, 250, 251); /* text-gray-50 */
|
|
margin: 0;
|
|
}
|
|
|
|
.attack-menu__close {
|
|
background: none;
|
|
border: none;
|
|
color: rgb(156, 163, 175); /* text-gray-400 */
|
|
font-size: 1.5rem;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
padding: 0.25rem;
|
|
transition: color 0.2s ease;
|
|
}
|
|
|
|
.attack-menu__close:hover {
|
|
color: rgb(249, 250, 251); /* text-gray-50 */
|
|
}
|
|
|
|
/**
|
|
* Active Pokemon name display.
|
|
*/
|
|
.attack-menu__pokemon {
|
|
padding: 0.75rem 1.25rem;
|
|
font-size: 0.875rem; /* text-sm */
|
|
font-weight: 600; /* font-semibold */
|
|
color: rgb(147, 197, 253); /* text-blue-300 */
|
|
background: rgba(59, 130, 246, 0.1); /* bg-blue-500 with low opacity */
|
|
border-bottom: 1px solid rgba(75, 85, 99, 0.5);
|
|
}
|
|
|
|
/**
|
|
* Warning message for global disable reasons.
|
|
*/
|
|
.attack-menu__warning {
|
|
padding: 0.75rem 1.25rem;
|
|
font-size: 0.875rem; /* text-sm */
|
|
color: rgb(252, 165, 165); /* text-red-300 */
|
|
background: rgba(239, 68, 68, 0.1); /* bg-red-500 with low opacity */
|
|
border-bottom: 1px solid rgba(75, 85, 99, 0.5);
|
|
}
|
|
|
|
/**
|
|
* Attack list container.
|
|
*/
|
|
.attack-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/**
|
|
* Individual attack entry - card-like button.
|
|
*/
|
|
.attack-entry {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 0.875rem;
|
|
background: rgba(55, 65, 81, 0.8); /* bg-gray-700 with opacity */
|
|
border: 2px solid rgba(75, 85, 99, 0.5); /* border-gray-600 */
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
text-align: left;
|
|
position: relative;
|
|
}
|
|
|
|
.attack-entry:hover:not(:disabled) {
|
|
background: rgba(55, 65, 81, 1);
|
|
border-color: rgb(96, 165, 250); /* border-blue-400 */
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.attack-entry:active:not(:disabled) {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
/**
|
|
* Disabled attack entry.
|
|
*/
|
|
.attack-entry--disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
border-color: rgba(107, 114, 128, 0.3);
|
|
}
|
|
|
|
.attack-entry--disabled:hover {
|
|
transform: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
/**
|
|
* Attack entry header with name and damage.
|
|
*/
|
|
.attack-entry__header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.attack-entry__name {
|
|
font-size: 1rem;
|
|
font-weight: 600; /* font-semibold */
|
|
color: rgb(249, 250, 251); /* text-gray-50 */
|
|
}
|
|
|
|
.attack-entry__damage {
|
|
font-size: 1.25rem; /* text-xl */
|
|
font-weight: 700; /* font-bold */
|
|
color: rgb(252, 165, 165); /* text-red-300 */
|
|
}
|
|
|
|
/**
|
|
* Energy cost display.
|
|
*/
|
|
.attack-entry__cost {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.energy-icon {
|
|
width: 1.5rem;
|
|
height: 1.5rem;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.75rem; /* text-xs */
|
|
font-weight: 700; /* font-bold */
|
|
color: white;
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/**
|
|
* Effect description text.
|
|
*/
|
|
.attack-entry__effect {
|
|
font-size: 0.75rem; /* text-xs */
|
|
color: rgb(209, 213, 219); /* text-gray-300 */
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/**
|
|
* Disabled reason tooltip.
|
|
*/
|
|
.attack-entry__tooltip {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
margin-bottom: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background: rgba(17, 24, 39, 0.95); /* bg-gray-900 */
|
|
color: rgb(249, 250, 251); /* text-gray-50 */
|
|
font-size: 0.75rem; /* text-xs */
|
|
border-radius: 0.375rem;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s ease;
|
|
z-index: 10;
|
|
}
|
|
|
|
.attack-entry--disabled:hover .attack-entry__tooltip {
|
|
opacity: 1;
|
|
}
|
|
|
|
/**
|
|
* Empty state message.
|
|
*/
|
|
.attack-menu__empty {
|
|
padding: 2rem 1.25rem;
|
|
text-align: center;
|
|
color: rgb(156, 163, 175); /* text-gray-400 */
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
/**
|
|
* Footer with cancel button.
|
|
*/
|
|
.attack-menu__footer {
|
|
padding: 0.75rem 1.25rem;
|
|
border-top: 1px solid rgba(75, 85, 99, 0.5);
|
|
}
|
|
|
|
.cancel-button {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
background: rgba(107, 114, 128, 0.3); /* bg-gray-500 with opacity */
|
|
border: 2px solid rgba(107, 114, 128, 0.5);
|
|
border-radius: 0.5rem;
|
|
color: rgb(229, 231, 235); /* text-gray-200 */
|
|
font-size: 0.875rem; /* text-sm */
|
|
font-weight: 600; /* font-semibold */
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.cancel-button:hover {
|
|
background: rgba(107, 114, 128, 0.5);
|
|
border-color: rgb(107, 114, 128);
|
|
}
|
|
|
|
/**
|
|
* Mobile responsive styles.
|
|
*/
|
|
@media (max-width: 768px) {
|
|
.attack-menu-backdrop {
|
|
align-items: flex-end;
|
|
padding: 0;
|
|
}
|
|
|
|
.attack-menu {
|
|
max-width: 100%;
|
|
border-bottom-left-radius: 0;
|
|
border-bottom-right-radius: 0;
|
|
max-height: 80vh;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transition animations.
|
|
*/
|
|
.attack-menu-fade-enter-active,
|
|
.attack-menu-fade-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.attack-menu-fade-enter-active .attack-menu,
|
|
.attack-menu-fade-leave-active .attack-menu {
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.attack-menu-fade-enter-from,
|
|
.attack-menu-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.attack-menu-fade-enter-from .attack-menu {
|
|
transform: translateY(2rem);
|
|
}
|
|
|
|
.attack-menu-fade-leave-to .attack-menu {
|
|
transform: translateY(2rem);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.attack-menu-fade-enter-from .attack-menu,
|
|
.attack-menu-fade-leave-to .attack-menu {
|
|
transform: translateY(100%);
|
|
}
|
|
}
|
|
</style>
|