Merge pull request 'feature/gameplay-ui-improvements' (#2) from feature/gameplay-ui-improvements into main

Reviewed-on: #2
This commit is contained in:
cal 2026-02-07 05:01:16 +00:00
commit 9ba611bfee
8 changed files with 2481 additions and 56 deletions

View File

@ -0,0 +1,204 @@
# Runner Display Alternatives - Design Proposals
## Current Implementation Issues
- Baseball diamond is visually cluttered
- Runners are represented by small numbered circles that are hard to see
- Player information is not immediately accessible
- Takes up significant vertical space
- Doesn't provide quick access to runner card details
- UX is confusing - users want to quickly glance at cards for each runner
## Design Goals
- **Glanceable**: See runner status instantly without searching
- **Compact**: Use less vertical space (mobile-first)
- **Card Access**: Quick access to view runner player cards
- **Clear State**: Immediately obvious which bases are occupied
- **Professional**: Modern, polished appearance
---
## Option 1: Horizontal Runner Card Bar
### Visual Description
A sleek horizontal bar showing 3 slots (1st, 2nd, 3rd) with mini player cards that expand on tap/click.
```
┌────────────────────────────────────────────────────────┐
│ RUNNERS ON BASE │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 1B │ │ 2B │ │ 3B │ │
│ │ ──── │ │[IMG] │ │ ──── │ │
│ │ │ │ J.D. │ │ │ │
│ │ Empty│ │ │ │ Empty│ │
│ └──────┘ └──────┘ └──────┘ │
└────────────────────────────────────────────────────────┘
```
### Features
- **Empty State**: Gray/muted card with dashed border and "Empty" text
- **Occupied State**: Player headshot, name, and team color border
- **Tap to Expand**: Shows full player card modal
- **Visual Hierarchy**: Occupied bases use team colors and are visually prominent
- **Badge Indicator**: Small circular badge with base number (1/2/3) on top-right
### Pros
- ✅ Very compact (single row)
- ✅ Clear visual distinction between empty/occupied
- ✅ Easy touch targets for mobile
- ✅ Horizontally balanced layout
- ✅ Player images make it personal/engaging
### Cons
- ❌ May be cramped on very small screens
- ❌ Requires good player headshots for best effect
### CSS Approach
- Grid layout with 3 equal columns
- Card-style mini containers
- Smooth hover/tap states with scale transform
- Team color accent on occupied cards
---
## Option 2: Compact Diamond Indicator Strip
### Visual Description
Minimalist strip showing bases as dots/indicators with runner names inline.
```
┌────────────────────────────────────────────────────────┐
│ ○ 1B Empty ● 2B J. Doe (#24) ○ 3B Empty │
│ Tap any runner to view card │
└────────────────────────────────────────────────────────┘
```
### Features
- **Dot Indicators**: Hollow circle = empty, filled circle = occupied
- **Inline Text**: Base label + player name (abbreviated) + jersey number
- **Color Coding**: Dot and text use team color when occupied
- **Single Line**: Extremely compact
- **Hover State**: Underline runner names, show "tap to view card" tooltip
### Pros
- ✅ Absolute minimal space usage
- ✅ Text-based, works without images
- ✅ Extremely fast to scan
- ✅ Works well in both light/dark mode
### Cons
- ❌ Less visually engaging
- ❌ No player images visible
- ❌ May feel too minimal/plain
- ❌ Harder to tap on mobile (smaller targets)
### CSS Approach
- Flexbox with space-between
- Dot using ::before pseudo-element
- Text truncation for long names
- Underline decoration on hover/active
---
## Option 3: Stacked Runner Cards (Recommended)
### Visual Description
Card-based interface showing only occupied bases as expandable cards in a horizontal stack. Empty bases are represented by small placeholder chips.
```
┌────────────────────────────────────────────────────────┐
│ RUNNERS ON BASE │
│ │
│ 1B: ─ 2B: [Card] 3B: ─ │
│ ┌──────────┐ │
│ │ [IMG] │ │
│ │ J. Doe │ │
│ │ #24 2B │ │
│ │ [View] │ │
│ └──────────┘ │
└────────────────────────────────────────────────────────┘
```
### Features
- **Empty Bases**: Small chip/badge showing "1B: —" (muted, minimal)
- **Occupied Bases**: Full mini card with:
- Player headshot (circular)
- Player name (bold)
- Jersey number + position
- Team color accent/border
- "View Card" button
- **Responsive**: Cards stack vertically on narrow screens
- **Visual Priority**: Occupied bases are much larger and more prominent
- **Quick Actions**: Button directly on card for instant access
### Pros
- ✅ Best balance of compact + informative
- ✅ Only shows detail where needed (occupied bases)
- ✅ Clear call-to-action with "View Card" button
- ✅ Player images add personality
- ✅ Responsive layout adapts well to mobile
- ✅ Professional, modern card-based UI pattern
### Cons
- ❌ Slightly more vertical space when bases loaded
- ❌ Requires player headshots for best experience
### CSS Approach
- Flexbox/Grid hybrid (flex-wrap for responsive)
- Empty bases: inline-flex badge (h-8, minimal)
- Occupied bases: w-32 card with padding
- Shadow and border-radius for card depth
- Team color as left border accent (4px)
### Implementation Details
```vue
<div class="runners-container">
<!-- Empty Base -->
<div class="runner-empty">
<span class="base-label">1B:</span>
<span class="empty-indicator"></span>
</div>
<!-- Occupied Base -->
<div class="runner-card" :style="{ borderColor: teamColor }">
<img :src="player.headshot" class="runner-avatar" />
<div class="runner-info">
<p class="runner-name">{{ player.name }}</p>
<p class="runner-meta">#{{ player.jersey }} • 2B</p>
</div>
<button @click="viewCard(player)" class="btn-view-card">
View Card
</button>
</div>
</div>
```
---
## Recommendation: Option 3 (Stacked Runner Cards)
### Why This Is Best
1. **Optimizes for the common case**: Most at-bats have 0-1 runners, so minimizing empty state is key
2. **Mobile-first**: Large tap targets, clear hierarchy, easy to use one-handed
3. **Information density**: Shows exactly what's needed without clutter
4. **Modern UX**: Card-based pattern is familiar and professional
5. **Scalable**: Works equally well with 1, 2, or 3 runners on base
### Responsive Behavior
- **Mobile (< 640px)**: Stack cards vertically, full width
- **Tablet (640-1024px)**: Horizontal row, cards side-by-side
- **Desktop (> 1024px)**: Same as tablet, more breathing room
### Accessibility
- Semantic HTML with proper ARIA labels
- Keyboard navigation support
- High contrast for empty vs occupied states
- Screen reader announces "Runner on second base: J. Doe, number 24"
---
## Next Steps
1. Choose preferred option (or hybrid approach)
2. Create Vue component implementation
3. Test on mobile devices
4. Gather user feedback
5. Iterate based on real gameplay usage

View File

@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Runner Display Mockups - Options 1, 3, & 4</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Option 1 Styles */
.runner-card-option1 {
transition: all 0.2s ease;
}
.runner-card-option1:hover {
transform: scale(1.05);
}
.runner-card-option1.occupied {
cursor: pointer;
}
/* Option 3 Styles */
.runner-card-option3 {
transition: all 0.2s ease;
}
.runner-card-option3:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
}
/* Option 4 Styles */
.runner-card-option4 {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.runner-card-option4.empty {
cursor: default;
}
.runner-card-option4.occupied {
cursor: pointer;
}
.runner-card-option4.occupied:hover:not(.expanded) {
transform: translateX(4px);
background: rgba(255, 255, 255, 0.95);
}
/* Expanded state - runner card grows */
.runner-card-option4.expanded {
transform: scale(1.02);
z-index: 10;
}
/* Pulse animation for occupied bases */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
}
.pulse-occupied {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Slide-in animation for Option 4 */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-in {
animation: slideInRight 0.3s ease-out;
}
/* Matchup card styles */
.matchup-card {
transition: all 0.3s ease;
}
@keyframes pulseGlowBlue {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
@keyframes pulseGlowGreen {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(16, 185, 129, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
/* Expand animation */
.expand-height {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Card image fade in */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
</style>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-2">Runner Display Mockups</h1>
<p class="text-gray-600">Interactive mockups of Options 1, 3, and 4 for runners on base display</p>
</div>
<!-- Toggle Scenarios -->
<div class="mb-8 bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold mb-4">Test Scenarios</h2>
<div class="flex flex-wrap gap-3">
<button onclick="setScenario('empty')" class="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition">
All Empty
</button>
<button onclick="setScenario('single')" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition">
Runner on 2B
</button>
<button onclick="setScenario('double')" class="px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium transition">
Runners on 1B & 3B
</button>
<button onclick="setScenario('loaded')" class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition">
Bases Loaded
</button>
</div>
</div>
<!-- OPTION 1: Horizontal Runner Card Bar -->
<section class="mb-12 bg-white rounded-xl shadow-lg p-8">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Option 1: Horizontal Runner Card Bar</h2>
<p class="text-gray-600">Three equal-width cards showing all bases with equal visual weight</p>
</div>
<div class="bg-gradient-to-b from-gray-50 to-gray-100 rounded-lg p-6">
<h3 class="text-sm font-semibold text-gray-700 mb-4 uppercase tracking-wide">Runners on Base</h3>
<!-- Desktop View -->
<div class="hidden md:grid md:grid-cols-3 gap-4" id="option1-desktop">
<!-- Cards will be inserted here -->
</div>
<!-- Mobile View -->
<div class="md:hidden space-y-3" id="option1-mobile">
<!-- Cards will be inserted here -->
</div>
</div>
<!-- Pros/Cons -->
<div class="mt-6 grid md:grid-cols-2 gap-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 class="font-semibold text-green-800 mb-2">✅ Pros</h4>
<ul class="text-sm text-green-700 space-y-1">
<li>• Very compact (single row)</li>
<li>• Clear visual distinction</li>
<li>• Easy touch targets</li>
<li>• Balanced layout</li>
</ul>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 class="font-semibold text-red-800 mb-2">❌ Cons</h4>
<ul class="text-sm text-red-700 space-y-1">
<li>• Always shows all 3 cards</li>
<li>• May feel cluttered when bases empty</li>
<li>• Less visual hierarchy</li>
</ul>
</div>
</div>
</section>
<!-- OPTION 3: Stacked Runner Cards -->
<section class="mb-12 bg-white rounded-xl shadow-lg p-8">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Option 3: Stacked Runner Cards</h2>
<p class="text-gray-600">Only occupied bases get prominent cards, empty bases are minimal chips</p>
</div>
<div class="bg-gradient-to-b from-blue-50 to-blue-100 rounded-lg p-6">
<h3 class="text-sm font-semibold text-gray-700 mb-4 uppercase tracking-wide">Runners on Base</h3>
<div class="flex flex-wrap gap-3" id="option3-container">
<!-- Cards will be inserted here -->
</div>
</div>
<!-- Pros/Cons -->
<div class="mt-6 grid md:grid-cols-2 gap-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 class="font-semibold text-green-800 mb-2">✅ Pros</h4>
<ul class="text-sm text-green-700 space-y-1">
<li>• Optimizes for common case (0-1 runners)</li>
<li>• Clear visual hierarchy</li>
<li>• Easy card access with button</li>
<li>• Mobile-first design</li>
<li>• Professional card-based UI</li>
</ul>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 class="font-semibold text-red-808 mb-2">❌ Cons</h4>
<ul class="text-sm text-red-700 space-y-1">
<li>• More horizontal space with 3 runners</li>
<li>• Requires player headshots</li>
</ul>
</div>
</div>
</section>
<!-- OPTION 4: Expanding Runner Cards with Matchup (NEW - RECOMMENDED) -->
<section class="mb-12 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-xl shadow-lg p-8 border-2 border-purple-200">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">
Option 4: Expanding Runner Cards with Matchup
<span class="ml-2 text-sm bg-purple-600 text-white px-3 py-1 rounded-full">NEW RECOMMENDATION</span>
</h2>
<p class="text-gray-600">
Runners list on left, catcher on right. <strong>Click a runner to expand it in place</strong> and show full card + catcher matchup.
<strong>Only visible when runners on base.</strong>
</p>
</div>
<!-- Container with split layout -->
<div id="option4-container" class="bg-white rounded-lg shadow-md overflow-hidden">
<!-- Split Layout: Runners List | Catcher Card -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-0">
<!-- LEFT: Runners List (with expandable cards) -->
<div class="border-r border-gray-200 p-4">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">Runners on Base</h3>
<div id="option4-list" class="space-y-2">
<!-- Runner cards will be inserted here -->
</div>
<!-- Hidden state message -->
<div id="option4-hidden" class="hidden text-center py-6 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">No runners on base</p>
<p class="text-xs text-gray-400 mt-1">Component hidden</p>
</div>
</div>
<!-- RIGHT: Catcher Card -->
<div class="p-4 bg-gray-50" id="option4-catcher-container">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">Catcher</h3>
<!-- Collapsed state - minimal card -->
<div id="catcher-collapsed" class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
<img src="https://via.placeholder.com/80/6b7280/ffffff?text=C" class="w-full h-full object-cover">
</div>
<div class="flex-1">
<div class="text-sm font-bold text-gray-900">Buster Posey</div>
<div class="text-xs text-gray-600">#28 • C</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 text-center">
Click a runner to see matchup →
</div>
</div>
<!-- Expanded state - full card (hidden by default) -->
<div id="catcher-expanded" class="hidden matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg fade-in" style="animation: pulseGlowGreen 2s ease-in-out infinite;">
<!-- Card Header -->
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<span class="font-bold text-white/90">CATCHER</span>
<span class="text-white/70">C</span>
<span class="truncate flex-1 text-right font-bold">Buster Posey</span>
</div>
<!-- Card Image -->
<div class="p-0">
<img src="https://via.placeholder.com/400x550/6b7280/ffffff?text=Catcher+Card" class="w-full h-auto">
</div>
</div>
</div>
</div>
</div>
<!-- Pros/Cons -->
<div class="mt-6 grid md:grid-cols-2 gap-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 class="font-semibold text-green-800 mb-2">✅ Pros</h4>
<ul class="text-sm text-green-700 space-y-1">
<li><strong>Expands in place</strong> - no jarring screen flip</li>
<li><strong>Smooth transition</strong> - runner card grows to show full player card</li>
<li>• Catcher card appears simultaneously for matchup context</li>
<li>• Only shows when needed (on_base_code > 0)</li>
<li>• Consistent with pitcher vs batter styling</li>
<li>• Clear visual hierarchy</li>
</ul>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 class="font-semibold text-red-800 mb-2">❌ Cons</h4>
<ul class="text-sm text-red-700 space-y-1">
<li>• Requires extra click to see full cards</li>
<li>• Slightly more complex animation</li>
</ul>
</div>
</div>
<!-- Implementation Notes -->
<div class="mt-6 bg-purple-100 border border-purple-300 rounded-lg p-4">
<h4 class="font-semibold text-purple-900 mb-2">💡 Implementation Notes</h4>
<ul class="text-sm text-purple-800 space-y-1">
<li>• Component only renders when <code class="bg-purple-200 px-1 rounded">thisPlay.on_base_code > 0</code></li>
<li>• Default: Runners list (left) with compact cards + Catcher summary (right)</li>
<li>• Click runner → Runner card expands in place to show full player card</li>
<li>• Simultaneously, catcher card expands to show full matchup</li>
<li>• Click again or click another runner to collapse/switch</li>
<li>• Smooth height transition using CSS max-height animation</li>
</ul>
</div>
</section>
<!-- Comparison -->
<section class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-xl shadow-lg p-8">
<h2 class="text-2xl font-bold mb-4">Final Recommendation: Option 4</h2>
<div class="space-y-2 text-lg">
<p>🎯 <strong>Smooth in-place expansion</strong> - Runner card grows to reveal full player card</p>
<p>🔄 <strong>Integrated matchup view</strong> - Catcher card appears alongside for comparison</p>
<p>👁️ <strong>No screen flipping</strong> - Everything expands/collapses in the same layout</p>
<p>📱 <strong>Mobile-first design</strong> - Works seamlessly on all screen sizes</p>
<p><strong>Strategic context</strong> - See runner vs catcher for steal decisions</p>
<p><strong>Clean UX</strong> - Compact by default, detailed on demand</p>
</div>
</section>
</div>
<script>
// Sample player data
const players = {
first: { name: 'Mike Trout', number: '27', team: 'blue', headshot: 'https://via.placeholder.com/80/3b82f6/ffffff?text=MT' },
second: { name: 'Aaron Judge', number: '99', team: 'red', headshot: 'https://via.placeholder.com/80/ef4444/ffffff?text=AJ' },
third: { name: 'Shohei Ohtani', number: '17', team: 'green', headshot: 'https://via.placeholder.com/80/10b981/ffffff?text=SO' }
};
const teamColors = {
blue: { border: '#3b82f6', bg: '#dbeafe' },
red: { border: '#ef4444', bg: '#fee2e2' },
green: { border: '#10b981', bg: '#d1fae5' }
};
let currentScenario = {
first: false,
second: false,
third: false
};
let selectedRunner = null;
function setScenario(type) {
switch(type) {
case 'empty':
currentScenario = { first: false, second: false, third: false };
break;
case 'single':
currentScenario = { first: false, second: true, third: false };
break;
case 'double':
currentScenario = { first: true, second: false, third: true };
break;
case 'loaded':
currentScenario = { first: true, second: true, third: true };
break;
}
selectedRunner = null;
updateDisplay();
}
function selectRunner(base) {
if (!currentScenario[base]) return; // Can't select empty base
// Toggle selection
if (selectedRunner === base) {
selectedRunner = null;
} else {
selectedRunner = base;
}
updateOption4();
}
function updateDisplay() {
updateOption1();
updateOption3();
updateOption4();
}
function updateOption1() {
const desktopContainer = document.getElementById('option1-desktop');
const mobileContainer = document.getElementById('option1-mobile');
desktopContainer.innerHTML = '';
mobileContainer.innerHTML = '';
['first', 'second', 'third'].forEach(base => {
const baseLabel = base === 'first' ? '1ST' : base === 'second' ? '2ND' : '3RD';
const player = players[base];
const color = teamColors[player.team];
// Desktop card
const desktopCard = document.createElement('div');
if (currentScenario[base]) {
desktopCard.className = 'runner-card-option1 occupied bg-white rounded-lg border-2 p-4 text-center pulse-occupied';
desktopCard.style.borderColor = color.border;
desktopCard.style.backgroundColor = color.bg;
desktopCard.innerHTML = `
<div class="text-xs font-bold mb-2" style="color: ${color.border}">${baseLabel} BASE</div>
<div class="relative w-16 h-16 mx-auto mb-2">
<img src="${player.headshot}" class="w-full h-full rounded-full border-2 object-cover" style="border-color: ${color.border}">
<div class="absolute -top-1 -right-1 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white" style="background: ${color.border}">
${base === 'first' ? '1' : base === 'second' ? '2' : '3'}
</div>
</div>
<div class="text-sm font-bold text-gray-900">${player.name}</div>
<div class="text-xs text-gray-600">#${player.number}</div>
`;
} else {
desktopCard.className = 'runner-card-option1 bg-white rounded-lg border-2 border-gray-200 p-4 text-center';
desktopCard.innerHTML = `
<div class="text-xs font-bold text-gray-500 mb-2">${baseLabel} BASE</div>
<div class="w-16 h-16 mx-auto mb-2 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
<span class="text-gray-400 text-2xl"></span>
</div>
<div class="text-sm text-gray-400 font-medium">Empty</div>
`;
}
desktopContainer.appendChild(desktopCard);
// Mobile card
const mobileCard = document.createElement('div');
if (currentScenario[base]) {
mobileCard.className = 'runner-card-option1 occupied bg-white rounded-lg border-2 p-3 flex items-center';
mobileCard.style.borderColor = color.border;
mobileCard.style.backgroundColor = color.bg;
mobileCard.innerHTML = `
<div class="text-xs font-bold w-12" style="color: ${color.border}">${baseLabel}</div>
<img src="${player.headshot}" class="w-12 h-12 rounded-full border-2 object-cover" style="border-color: ${color.border}">
<div class="ml-3 flex-1">
<div class="text-sm font-bold text-gray-900">${player.name}</div>
<div class="text-xs text-gray-600">#${player.number}</div>
</div>
`;
} else {
mobileCard.className = 'runner-card-option1 bg-white rounded-lg border-2 border-gray-200 p-3 flex items-center';
mobileCard.innerHTML = `
<div class="text-xs font-bold text-gray-500 w-12">${baseLabel}</div>
<div class="w-12 h-12 rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
<span class="text-gray-400 text-xl"></span>
</div>
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
`;
}
mobileContainer.appendChild(mobileCard);
});
}
function updateOption3() {
const container = document.getElementById('option3-container');
container.innerHTML = '';
['first', 'second', 'third'].forEach(base => {
const baseLabel = base === 'first' ? '1B' : base === 'second' ? '2B' : '3B';
if (currentScenario[base]) {
const player = players[base];
const color = teamColors[player.team];
const card = document.createElement('div');
card.className = 'runner-card-option3 bg-white rounded-lg shadow-md p-4 cursor-pointer';
card.style.borderLeft = `4px solid ${color.border}`;
card.style.minWidth = '160px';
card.innerHTML = `
<div class="flex items-start gap-3 mb-3">
<div class="relative flex-shrink-0">
<img src="${player.headshot}" class="w-14 h-14 rounded-full border-2 object-cover" style="border-color: ${color.border}">
<div class="absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold text-white" style="background: ${color.border}">
${base === 'first' ? '1' : base === 'second' ? '2' : '3'}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-gray-900 truncate">${player.name}</div>
<div class="text-xs text-gray-600">#${player.number} • ${baseLabel}</div>
</div>
</div>
<button class="w-full px-3 py-1.5 text-xs font-semibold rounded-md transition text-white hover:opacity-90" style="background: ${color.border}">
View Card
</button>
`;
container.appendChild(card);
} else {
const chip = document.createElement('div');
chip.className = 'inline-flex items-center px-3 py-1.5 bg-gray-200 text-gray-500 rounded-full text-sm font-medium';
chip.innerHTML = `
<span class="font-bold mr-1">${baseLabel}:</span>
<span></span>
`;
container.appendChild(chip);
}
});
}
function updateOption4() {
const hasRunners = currentScenario.first || currentScenario.second || currentScenario.third;
const listEl = document.getElementById('option4-list');
const hiddenEl = document.getElementById('option4-hidden');
const catcherCollapsed = document.getElementById('catcher-collapsed');
const catcherExpanded = document.getElementById('catcher-expanded');
if (!hasRunners) {
listEl.classList.add('hidden');
hiddenEl.classList.remove('hidden');
return;
}
listEl.classList.remove('hidden');
hiddenEl.classList.add('hidden');
// Clear and rebuild runner list
listEl.innerHTML = '';
['first', 'second', 'third'].forEach(base => {
const baseLabel = base === 'first' ? '1B' : base === 'second' ? '2B' : '3B';
const isExpanded = selectedRunner === base;
const card = document.createElement('div');
card.onclick = () => selectRunner(base);
if (currentScenario[base]) {
const player = players[base];
const color = teamColors[player.team];
card.className = `runner-card-option4 occupied bg-white border-l-4 rounded-lg shadow-sm slide-in ${isExpanded ? 'expanded' : ''}`;
card.style.borderColor = color.border;
// Collapsed state (summary)
const summaryHTML = `
<div class="p-2 flex items-center">
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" style="border-color: ${color.border}">
<img src="${player.headshot}" class="w-full h-full object-cover">
</div>
<div class="ml-3 flex-1">
<div class="text-sm font-bold text-gray-900">${player.name}</div>
<div class="text-xs text-gray-600">#${player.number} • ${baseLabel}</div>
</div>
<div class="text-xs text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
`;
// Expanded state (full card)
const expandedHTML = isExpanded ? `
<div class="expand-height overflow-hidden fade-in" style="max-height: 800px;">
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden" style="animation: pulseGlowBlue 2s ease-in-out infinite;">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
<span class="font-bold text-white/90">RUNNER</span>
<span class="text-white/70">${baseLabel}</span>
<span class="truncate flex-1 text-right font-bold">${player.name}</span>
</div>
<div class="p-0">
<img src="https://via.placeholder.com/400x550/${color.border.substring(1)}/ffffff?text=${player.name.replace(' ', '+')}" class="w-full h-auto">
</div>
</div>
</div>
` : '';
card.innerHTML = summaryHTML + expandedHTML;
} else {
card.className = 'runner-card-option4 empty bg-gray-50 border-l-4 border-gray-300 rounded-lg p-2 flex items-center';
card.innerHTML = `
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-sm font-bold">${baseLabel}</span>
</div>
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
`;
}
listEl.appendChild(card);
});
// Toggle catcher card state
if (selectedRunner && currentScenario[selectedRunner]) {
catcherCollapsed.classList.add('hidden');
catcherExpanded.classList.remove('hidden');
} else {
catcherCollapsed.classList.remove('hidden');
catcherExpanded.classList.add('hidden');
}
}
// Add click handlers for Options 1 & 3
document.addEventListener('click', (e) => {
if (e.target.closest('.runner-card-option1.occupied') ||
e.target.closest('.runner-card-option3')) {
alert('In the real app, this would open the player card modal!');
}
});
// Initialize
setScenario('single');
</script>
</body>
</html>

View File

@ -1,40 +1,8 @@
<template>
<div class="current-situation">
<!-- Side-by-Side Card Layout -->
<div class="grid grid-cols-2 gap-4">
<!-- Current Pitcher Card -->
<button
v-if="currentPitcher"
:class="[
'player-card pitcher-card card-transition',
pitcherCardClasses
]"
@click="openPlayerCard('pitcher')"
>
<!-- Card Header -->
<div class="card-header pitcher-header">
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
<span class="position-info">P</span>
<span class="player-name">{{ pitcherName }}</span>
</div>
<!-- Card Image -->
<div class="card-image-container">
<img
v-if="pitcherPlayer?.image"
:src="pitcherPlayer.image"
:alt="`${pitcherName} card`"
class="card-image"
@error="handleImageError"
>
<div v-else class="card-placeholder pitcher-placeholder">
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
<span class="placeholder-label">No Card Image</span>
</div>
</div>
</button>
<!-- Current Batter Card -->
<!-- Side-by-Side Card Layout (stacked on mobile) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Current Batter Card (LEFT on desktop, TOP on mobile) -->
<button
v-if="currentBatter"
:class="[
@ -65,6 +33,38 @@
</div>
</div>
</button>
<!-- Current Pitcher Card (RIGHT on desktop, BOTTOM on mobile) -->
<button
v-if="currentPitcher"
:class="[
'player-card pitcher-card card-transition',
pitcherCardClasses
]"
@click="openPlayerCard('pitcher')"
>
<!-- Card Header -->
<div class="card-header pitcher-header">
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
<span class="position-info">P</span>
<span class="player-name">{{ pitcherName }}</span>
</div>
<!-- Card Image -->
<div class="card-image-container">
<img
v-if="pitcherPlayer?.image"
:src="pitcherPlayer.image"
:alt="`${pitcherName} card`"
class="card-image"
@error="handleImageError"
>
<div v-else class="card-placeholder pitcher-placeholder">
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
<span class="placeholder-label">No Card Image</span>
</div>
</div>
</button>
</div>
<!-- Empty State -->

View File

@ -62,15 +62,7 @@
<!-- Mobile Layout (Stacked) -->
<div class="lg:hidden space-y-6">
<!-- Game Board -->
<GameBoard
:runners="runnersState"
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:fielding-lineup="fieldingLineup"
/>
<!-- Current Situation (below diamond, above gameplay panel) -->
<!-- Current Situation -->
<CurrentSituation
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
@ -79,6 +71,16 @@
:pitcher-team-abbrev="pitcherTeamAbbrev"
/>
<!-- Runners on Base (with expandable cards) -->
<RunnersOnBase
:runners="runnersData"
:fielding-lineup="fieldingLineup"
:batting-team-color="battingTeamColor"
:fielding-team-color="fieldingTeamColor"
:batting-team-abbrev="batterTeamAbbrev"
:fielding-team-abbrev="pitcherTeamAbbrev"
/>
<!-- Decision Panel (Phase F3) -->
<DecisionPanel
v-if="showDecisions"
@ -127,17 +129,7 @@
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
<!-- Left Column: Game State -->
<div class="lg:col-span-2 space-y-6">
<!-- Game Board -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
<GameBoard
:runners="runnersState"
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:fielding-lineup="fieldingLineup"
/>
</div>
<!-- Current Situation (below diamond, above gameplay panel) -->
<!-- Current Situation -->
<CurrentSituation
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
@ -146,6 +138,16 @@
:pitcher-team-abbrev="pitcherTeamAbbrev"
/>
<!-- Runners on Base (with expandable cards) -->
<RunnersOnBase
:runners="runnersData"
:fielding-lineup="fieldingLineup"
:batting-team-color="battingTeamColor"
:fielding-team-color="fieldingTeamColor"
:batting-team-abbrev="batterTeamAbbrev"
:fielding-team-abbrev="pitcherTeamAbbrev"
/>
<!-- Decision Panel (Phase F3) -->
<DecisionPanel
v-if="showDecisions"
@ -326,8 +328,8 @@ import { useAuthStore } from '~/store/auth'
import { useUiStore } from '~/store/ui'
import { useWebSocket } from '~/composables/useWebSocket'
import { useGameActions } from '~/composables/useGameActions'
import GameBoard from '~/components/Game/GameBoard.vue'
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
import RunnersOnBase from '~/components/Game/RunnersOnBase.vue'
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
@ -412,6 +414,23 @@ const pitcherTeamAbbrev = computed(() => {
: gameState.value.away_team_abbrev ?? ''
})
// Team colors for runner/catcher matchup display
const battingTeamColor = computed(() => {
if (!gameState.value) return '#3b82f6'
// Use home team dice color when home team is batting, otherwise use a default blue
return gameState.value.half === 'bottom'
? `#${gameState.value.home_team_dice_color}`
: '#3b82f6'
})
const fieldingTeamColor = computed(() => {
if (!gameState.value) return '#10b981'
// Use home team dice color when home team is fielding, otherwise use a default green
return gameState.value.half === 'top'
? `#${gameState.value.home_team_dice_color}`
: '#10b981'
})
// Local UI state
const isLoading = ref(true)
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')

View File

@ -0,0 +1,187 @@
<template>
<div
:class="[
'runner-card',
runner ? 'occupied' : 'empty',
isExpanded ? 'expanded' : ''
]"
@click="handleClick"
>
<!-- Summary (always visible) -->
<div class="runner-summary">
<template v-if="runner">
<!-- Occupied base -->
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<img
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
:src="runnerPlayer.headshot || runnerPlayer.image"
:alt="runnerName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold text-sm">
{{ base }}
</div>
</div>
<div class="ml-3 flex-1">
<div class="text-sm font-bold text-gray-900">{{ runnerName }}</div>
<div class="text-xs text-gray-600">#{{ runnerNumber }} {{ base }}</div>
</div>
<div class="text-xs text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="['h-4 w-4 transition-transform', isExpanded ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</template>
<template v-else>
<!-- Empty base -->
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-sm font-bold">{{ base }}</span>
</div>
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
</template>
</div>
<!-- Expanded view (full card) -->
<div v-if="runner && isExpanded" class="runner-expanded">
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden matchup-card-blue">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
<span class="font-bold text-white/90">RUNNER</span>
<span class="text-white/70">{{ base }}</span>
<span class="truncate flex-1 text-right font-bold">{{ runnerName }}</span>
</div>
<div class="p-0">
<img
v-if="runnerPlayer?.image"
:src="runnerPlayer.image"
:alt="`${runnerName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ getRunnerInitials }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import { useGameStore } from '~/store/game'
interface Props {
base: '1B' | '2B' | '3B'
runner: LineupPlayerState | null
isExpanded: boolean
teamColor: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: []
}>()
const gameStore = useGameStore()
// Resolve player data from lineup
const runnerPlayer = computed(() => {
if (!props.runner) return null
const lineupEntry = gameStore.findPlayerInLineup(props.runner.lineup_id)
return lineupEntry?.player ?? null
})
const runnerName = computed(() => {
if (!runnerPlayer.value) return 'Unknown Runner'
return runnerPlayer.value.name
})
const runnerNumber = computed(() => {
// Try to extract jersey number from player data if available
// For now, default to a placeholder based on lineup_id
return props.runner?.lineup_id?.toString().padStart(2, '0') ?? '00'
})
const getRunnerInitials = computed(() => {
if (!runnerPlayer.value) return '?'
const parts = runnerPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return runnerPlayer.value.name.substring(0, 2).toUpperCase()
})
function handleClick() {
if (props.runner) {
emit('click')
}
}
</script>
<style scoped>
.runner-card {
@apply bg-white border-l-4 rounded-lg shadow-sm transition-all duration-300;
}
.runner-card.empty {
@apply bg-gray-50 border-gray-300;
}
.runner-card.occupied {
@apply cursor-pointer;
}
.runner-card.occupied:hover:not(.expanded) {
@apply transform translate-x-1;
background: rgba(255, 255, 255, 0.95);
}
.runner-card.expanded {
@apply transform scale-105 z-10;
}
.runner-summary {
@apply p-2 flex items-center;
}
.runner-expanded {
@apply overflow-hidden;
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease-out;
}
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowBlue {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
@keyframes expandHeight {
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 800px;
opacity: 1;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@ -0,0 +1,207 @@
<template>
<!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish -->
<div v-if="hasRunners" class="runners-on-base-container">
<!-- Split Layout: Runners List | Catcher Card -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 bg-white/20 rounded-lg shadow-md overflow-hidden">
<!-- LEFT: Runners List (with expandable cards) -->
<div class="border-r border-gray-200/50 p-4">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
Runners on Base
</h3>
<div class="space-y-2">
<!-- 1st Base -->
<RunnerCard
base="1B"
:runner="runners.first"
:is-expanded="selectedRunner === 'first'"
:team-color="battingTeamColor"
@click="toggleRunner('first')"
/>
<!-- 2nd Base -->
<RunnerCard
base="2B"
:runner="runners.second"
:is-expanded="selectedRunner === 'second'"
:team-color="battingTeamColor"
@click="toggleRunner('second')"
/>
<!-- 3rd Base -->
<RunnerCard
base="3B"
:runner="runners.third"
:is-expanded="selectedRunner === 'third'"
:team-color="battingTeamColor"
@click="toggleRunner('third')"
/>
</div>
</div>
<!-- RIGHT: Catcher Card -->
<div class="p-4 bg-gray-50/10">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
Catcher
</h3>
<!-- Collapsed state - minimal card -->
<div
v-if="!hasSelection"
class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300"
>
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
<img
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
:src="catcherPlayer.headshot || catcherPlayer.image"
:alt="catcherName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
C
</div>
</div>
<div class="flex-1">
<div class="text-sm font-bold text-gray-900">{{ catcherName }}</div>
<div class="text-xs text-gray-600">#{{ catcherNumber }} C</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 text-center">
Click a runner to see matchup
</div>
</div>
<!-- Expanded state - full card (when runner selected) -->
<div
v-else
class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg fade-in"
>
<!-- Card Header -->
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }">
{{ fieldingTeamAbbrev }}
</span>
<span class="text-white/70">C</span>
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
</div>
<!-- Card Image -->
<div class="p-0">
<img
v-if="catcherPlayer?.image"
:src="catcherPlayer.image"
:alt="`${catcherName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-green-700 to-green-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ getCatcherInitials }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game'
import RunnerCard from './RunnerCard.vue'
interface Props {
runners: {
first: LineupPlayerState | null
second: LineupPlayerState | null
third: LineupPlayerState | null
}
fieldingLineup?: Lineup[]
battingTeamColor?: string
fieldingTeamColor?: string
battingTeamAbbrev?: string
fieldingTeamAbbrev?: string
}
const props = withDefaults(defineProps<Props>(), {
fieldingLineup: () => [],
battingTeamColor: '#3b82f6',
fieldingTeamColor: '#10b981',
battingTeamAbbrev: '',
fieldingTeamAbbrev: '',
})
const gameStore = useGameStore()
const selectedRunner = ref<'first' | 'second' | 'third' | null>(null)
// Check if any runners on base
const hasRunners = computed(() => {
return !!(props.runners.first || props.runners.second || props.runners.third)
})
const hasSelection = computed(() => selectedRunner.value !== null)
// Get catcher from fielding lineup
const catcherLineup = computed(() => {
return props.fieldingLineup.find(p => p.position === 'C')
})
const catcherPlayer = computed(() => catcherLineup.value?.player ?? null)
const catcherName = computed(() => catcherPlayer.value?.name ?? 'Unknown Catcher')
const catcherNumber = computed(() => {
// Try to extract jersey number from player data if available
// For now, default to a placeholder
return '00'
})
const getCatcherInitials = computed(() => {
if (!catcherPlayer.value) return 'C'
const parts = catcherPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return catcherPlayer.value.name.substring(0, 2).toUpperCase()
})
function toggleRunner(base: 'first' | 'second' | 'third') {
// Can't select empty base
if (!props.runners[base]) return
// Toggle selection
if (selectedRunner.value === base) {
selectedRunner.value = null
} else {
selectedRunner.value = base
}
}
</script>
<style scoped>
.runners-on-base-container {
@apply mb-6;
}
.matchup-card {
animation: pulseGlowGreen 2s ease-in-out infinite;
}
@keyframes pulseGlowGreen {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(16, 185, 129, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@ -0,0 +1,606 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import RunnerCard from "~/components/Game/RunnerCard.vue";
import { useGameStore } from "~/store/game";
import type { LineupPlayerState } from "~/types/game";
describe("RunnerCard", () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
});
const mockRunner: LineupPlayerState = {
lineup_id: 1,
batting_order: 1,
position: "LF",
card_id: 101,
};
describe("empty base state", () => {
it("renders empty state when no runner provided", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.empty").exists()).toBe(true);
expect(wrapper.text()).toContain("Empty");
});
it("displays base label for empty base", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "2B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("2B");
});
it("shows hollow circle for empty base", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "3B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
const circle = wrapper.find(".rounded-full.border-dashed");
expect(circle.exists()).toBe(true);
});
it("does not emit click event for empty base", async () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
await wrapper.trigger("click");
expect(wrapper.emitted("click")).toBeUndefined();
});
});
describe("occupied base state", () => {
beforeEach(() => {
const gameStore = useGameStore();
// Set game state first so updateLineup knows the team ID
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
home_score: 0,
away_score: 0,
home_team_abbrev: "NYY",
away_team_abbrev: "BOS",
home_team_dice_color: "3b82f6",
current_batter: null,
current_pitcher: null,
on_first: null,
on_second: null,
on_third: null,
decision_phase: "idle",
play_count: 0,
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "https://example.com/trout.jpg",
headshot: "https://example.com/trout-headshot.jpg",
},
},
]);
});
it("renders occupied state when runner provided", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.occupied").exists()).toBe(true);
expect(wrapper.find(".runner-card.empty").exists()).toBe(false);
});
it("displays runner name", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("Mike Trout");
});
it("displays base label", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "2B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("2B");
});
it("displays runner number based on lineup_id", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("#01");
});
it("displays player headshot when available", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
const img = wrapper.find('img[alt="Mike Trout"]');
expect(img.exists()).toBe(true);
expect(img.attributes("src")).toBe(
"https://example.com/trout-headshot.jpg",
);
});
it("applies team color to border", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#ff0000",
},
});
const avatar = wrapper.find(".rounded-full.border-2");
expect(avatar.attributes("style")).toContain(
"border-color: #ff0000",
);
});
it("shows chevron icon when occupied", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
const chevron = wrapper.find("svg");
expect(chevron.exists()).toBe(true);
});
it("emits click event when clicked", async () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
await wrapper.trigger("click");
expect(wrapper.emitted("click")).toHaveLength(1);
});
});
describe("expanded state", () => {
beforeEach(() => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "https://example.com/trout-card.jpg",
headshot: "https://example.com/trout-headshot.jpg",
},
},
]);
});
it("does not show expanded view when collapsed", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").exists()).toBe(false);
});
it("shows expanded view when isExpanded is true", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").exists()).toBe(true);
});
it("displays full player card image when expanded", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
const cardImg = wrapper.find(
'.runner-expanded img[alt="Mike Trout card"]',
);
expect(cardImg.exists()).toBe(true);
expect(cardImg.attributes("src")).toBe(
"https://example.com/trout-card.jpg",
);
});
it("shows player initials when no card image available", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "",
headshot: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("MT");
});
it('displays "RUNNER" label in expanded header', () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "2B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").text()).toContain("RUNNER");
});
it("applies expanded class when isExpanded is true", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.expanded").exists()).toBe(true);
});
it("rotates chevron when expanded", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
const chevron = wrapper.find("svg");
expect(chevron.classes()).toContain("rotate-90");
});
});
describe("player name handling", () => {
it('shows "Unknown Runner" when player not found in store', () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("Unknown Runner");
});
it("extracts initials from first and last name", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Aaron Donald Judge",
image: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
// Should use first and last name (A + J)
expect(wrapper.text()).toContain("AJ");
});
it("handles single-word names", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Pele",
image: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("PE");
});
});
describe("base label variations", () => {
it("displays 1B correctly", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("1B");
});
it("displays 2B correctly", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "2B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("2B");
});
it("displays 3B correctly", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "3B",
runner: null,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("3B");
});
});
});

View File

@ -0,0 +1,577 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import RunnersOnBase from "~/components/Game/RunnersOnBase.vue";
import RunnerCard from "~/components/Game/RunnerCard.vue";
import { useGameStore } from "~/store/game";
import type { LineupPlayerState, Lineup } from "~/types/game";
describe("RunnersOnBase", () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
});
const mockRunnerFirst: LineupPlayerState = {
lineup_id: 1,
batting_order: 1,
position: "LF",
card_id: 101,
};
const mockRunnerSecond: LineupPlayerState = {
lineup_id: 2,
batting_order: 2,
position: "CF",
card_id: 102,
};
const mockRunnerThird: LineupPlayerState = {
lineup_id: 3,
batting_order: 3,
position: "RF",
card_id: 103,
};
const mockCatcher: Lineup = {
id: 1,
lineup_id: 4,
team_id: 1,
batting_order: 4,
position: "C",
is_active: true,
player: {
id: 104,
name: "Buster Posey",
image: "https://example.com/posey.jpg",
headshot: "https://example.com/posey-headshot.jpg",
},
};
const mockFieldingLineup: Lineup[] = [mockCatcher];
describe("component visibility", () => {
it("does not render when no runners on base", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: { first: null, second: null, third: null },
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
expect(wrapper.find(".runners-on-base-container").exists()).toBe(
false,
);
});
it("renders when at least one runner on base", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: null,
second: mockRunnerSecond,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
expect(wrapper.find(".runners-on-base-container").exists()).toBe(
true,
);
});
it("renders when bases loaded", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: mockRunnerSecond,
third: mockRunnerThird,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
expect(wrapper.find(".runners-on-base-container").exists()).toBe(
true,
);
});
});
describe("runner cards", () => {
it("renders three RunnerCard components", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards).toHaveLength(3);
});
it("passes correct base labels to RunnerCard components", async () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
await wrapper.vm.$nextTick();
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards.length).toBeGreaterThanOrEqual(3);
expect(runnerCards[0].props("base")).toBe("1B");
expect(runnerCards[1].props("base")).toBe("2B");
expect(runnerCards[2].props("base")).toBe("3B");
});
it("passes runner data to RunnerCard components", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: mockRunnerSecond,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards[0].props("runner")).toEqual(mockRunnerFirst);
expect(runnerCards[1].props("runner")).toEqual(mockRunnerSecond);
expect(runnerCards[2].props("runner")).toBeNull();
});
it("passes team color to RunnerCard components", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#ff0000",
fieldingTeamColor: "#00ff00",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
runnerCards.forEach((card) => {
expect(card.props("teamColor")).toBe("#ff0000");
});
});
});
describe("catcher display", () => {
it("shows collapsed catcher card by default", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
// Collapsed state shows border-l-4, expanded state shows .matchup-card
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
true,
);
expect(wrapper.find(".matchup-card").exists()).toBe(false);
});
it("displays catcher name", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
expect(wrapper.text()).toContain("Buster Posey");
});
it('shows "Unknown Catcher" when no catcher in lineup', () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: [],
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
expect(wrapper.text()).toContain("Unknown Catcher");
});
});
describe("runner selection", () => {
it("expands catcher card when runner is selected", async () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
home_score: 0,
away_score: 0,
home_team_abbrev: "NYY",
away_team_abbrev: "BOS",
home_team_dice_color: "3b82f6",
current_batter: null,
current_pitcher: null,
on_first: null,
on_second: null,
on_third: null,
decision_phase: "idle",
play_count: 0,
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "https://example.com/trout.jpg",
},
},
]);
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
await runnerCards[0].trigger("click");
// When runner selected, collapsed state hidden and expanded state shown
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
false,
);
expect(wrapper.find(".matchup-card").exists()).toBe(true);
});
it("collapses catcher card when runner is deselected", async () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
home_score: 0,
away_score: 0,
home_team_abbrev: "NYY",
away_team_abbrev: "BOS",
home_team_dice_color: "3b82f6",
current_batter: null,
current_pitcher: null,
on_first: null,
on_second: null,
on_third: null,
decision_phase: "idle",
play_count: 0,
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "https://example.com/trout.jpg",
},
},
]);
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
// Click to expand
await runnerCards[0].trigger("click");
expect(wrapper.find(".matchup-card").exists()).toBe(true);
// Click again to collapse
await runnerCards[0].trigger("click");
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
true,
);
expect(wrapper.find(".matchup-card").exists()).toBe(false);
});
it("switches selection when clicking different runner", async () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
home_score: 0,
away_score: 0,
home_team_abbrev: "NYY",
away_team_abbrev: "BOS",
home_team_dice_color: "3b82f6",
current_batter: null,
current_pitcher: null,
on_first: null,
on_second: null,
on_third: null,
decision_phase: "idle",
play_count: 0,
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "https://example.com/trout.jpg",
},
},
{
id: 2,
lineup_id: 2,
team_id: 1,
batting_order: 2,
position: "CF",
is_active: true,
player: {
id: 102,
name: "Aaron Judge",
image: "https://example.com/judge.jpg",
},
},
]);
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: mockRunnerSecond,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
// Select first runner
await runnerCards[0].trigger("click");
expect(runnerCards[0].props("isExpanded")).toBe(true);
expect(runnerCards[1].props("isExpanded")).toBe(false);
// Select second runner
await runnerCards[1].trigger("click");
expect(runnerCards[0].props("isExpanded")).toBe(false);
expect(runnerCards[1].props("isExpanded")).toBe(true);
});
});
describe("team information", () => {
it("displays team abbreviations when provided", async () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
home_score: 0,
away_score: 0,
home_team_abbrev: "NYY",
away_team_abbrev: "BOS",
home_team_dice_color: "3b82f6",
current_batter: null,
current_pitcher: null,
on_first: null,
on_second: null,
on_third: null,
decision_phase: "idle",
play_count: 0,
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "https://example.com/trout.jpg",
},
},
]);
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
battingTeamColor: "#3b82f6",
fieldingTeamColor: "#10b981",
battingTeamAbbrev: "BOS",
fieldingTeamAbbrev: "NYY",
},
});
// Click runner to expand and show team abbreviation
const runnerCards = wrapper.findAllComponents(RunnerCard);
await runnerCards[0].trigger("click");
expect(wrapper.text()).toContain("NYY");
});
it("uses default colors when not provided", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
runners: {
first: mockRunnerFirst,
second: null,
third: null,
},
fieldingLineup: mockFieldingLineup,
},
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards[0].props("teamColor")).toBe("#3b82f6");
});
});
});