CLAUDE: Compact defensive setup UI with glowing ring turn indicator

- Rewrite DefensiveSetup as inline segmented controls (~120px vs ~340px)
- Fix bug: read game state from useGameStore() instead of never-passed prop
- Remove turn indicator banner from DecisionPanel (replaced by green glow ring)
- Emoji fix: shield -> baseball glove
- Confirm button matches Roll Dice style (full-width, prominent)
- Infield options only show IF In/Corners when runner on 3rd
- Outfield row only renders in walk-off scenarios
- Hold pills only render for occupied bases
- 20 tests rewritten with Pinia store integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-12 14:44:46 -06:00
parent 87ae3c112a
commit 2d4dbe82eb
5 changed files with 1049 additions and 354 deletions

View File

@ -1,19 +1,5 @@
<template>
<div class="space-y-4">
<!-- Turn Indicator -->
<div
class="rounded-xl shadow-lg p-4 text-center"
:class="turnIndicatorClasses"
>
<div class="flex items-center justify-center gap-3">
<span class="text-3xl">{{ turnIcon }}</span>
<div>
<h2 class="text-xl font-bold">{{ turnTitle }}</h2>
<p class="text-sm opacity-90 mt-0.5">{{ turnSubtitle }}</p>
</div>
</div>
</div>
<!-- Decision Phase Content -->
<div v-if="phase !== 'idle'" class="space-y-4">
<!-- Defensive Phase -->
@ -165,42 +151,6 @@ const hasRunnersOnBase = computed(() => {
props.runners.third !== null
})
const turnIndicatorClasses = computed(() => {
if (props.isMyTurn) {
return 'bg-gradient-to-r from-green-600 to-green-700 text-white'
} else {
return 'bg-gradient-to-r from-gray-500 to-gray-600 text-white'
}
})
const turnIcon = computed(() => {
if (props.phase === 'idle') return '⏸️'
if (props.isMyTurn) return '✋'
return '⏳'
})
const turnTitle = computed(() => {
if (props.phase === 'idle') return 'Waiting for Next Play'
if (props.isMyTurn) {
return props.phase === 'defensive' ? 'Your Defensive Turn' : 'Your Offensive Turn'
} else {
return 'Opponent\'s Turn'
}
})
const turnSubtitle = computed(() => {
if (props.phase === 'idle') return 'No decisions needed right now'
if (props.isMyTurn) {
if (props.phase === 'defensive') {
return 'Set your defensive positioning and strategy'
} else {
return 'Choose your offensive approach and tactics'
}
} else {
return 'Waiting for opponent to make their decision'
}
})
const recentDecisions = computed(() => {
return props.decisionHistory.slice(0, 3)
})

View File

@ -1,99 +1,107 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<span class="text-2xl">🛡</span>
Defensive Setup
</h3>
<span
v-if="!isActive"
class="px-3 py-1 text-xs font-semibold rounded-full bg-gray-200 text-gray-600"
>
Opponent's Turn
</span>
</div>
<!-- Form -->
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- Infield Depth -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Infield Depth
</label>
<ButtonGroup
v-model="infieldDepth"
:options="infieldDepthOptions"
:disabled="!isActive"
size="md"
variant="primary"
vertical
/>
<div
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4"
:class="isActive ? 'ring-2 ring-green-500/60 shadow-green-500/20' : ''"
>
<form class="space-y-3" @submit.prevent="handleSubmit">
<!-- Header row: icon + title -->
<div class="flex items-center">
<h3 class="text-base font-bold text-gray-900 dark:text-white flex items-center gap-2">
<span class="text-lg">🧤</span>
Defense
</h3>
</div>
<!-- Outfield Depth -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Outfield Depth
</label>
<ButtonGroup
v-model="outfieldDepth"
:options="outfieldDepthOptions"
:disabled="!isActive"
size="md"
variant="primary"
/>
</div>
<!-- Visual Preview -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Current Setup
</h4>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Infield:</span>
<span class="ml-1 text-gray-900 dark:text-white">{{ infieldDisplay }}</span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Outfield:</span>
<span class="ml-1 text-gray-900 dark:text-white">{{ outfieldDisplay }}</span>
</div>
<div class="col-span-2">
<span class="font-medium text-gray-600 dark:text-gray-400">Holding:</span>
<span class="ml-1 text-gray-900 dark:text-white">{{ holdingDisplay }}</span>
</div>
<!-- Infield depth: segmented control (only shows extra options when runner on 3rd) -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
v-for="option in infieldOptions"
:key="option.value"
type="button"
:disabled="!isActive"
:class="segmentClasses(option.value === infieldDepth)"
class="flex-1 py-2 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="infieldDepth = option.value"
>
{{ option.label }}
</button>
</div>
</div>
<!-- Submit Button -->
<ActionButton
type="submit"
variant="success"
size="lg"
:disabled="!isActive"
:loading="submitting"
full-width
>
{{ submitButtonText }}
</ActionButton>
<!-- Outfield depth: only rendered when shallow option exists (walk-off scenario) -->
<div v-if="showOutfieldRow" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Outfield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
v-for="option in outfieldOptions"
:key="option.value"
type="button"
:disabled="!isActive"
:class="segmentClasses(option.value === outfieldDepth)"
class="flex-1 py-2 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="outfieldDepth = option.value"
>
{{ option.label }}
</button>
</div>
</div>
<!-- Hold runners: pill toggles for occupied bases only -->
<div v-if="occupiedBases.length > 0" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button
v-for="base in occupiedBases"
:key="base"
type="button"
:disabled="!isActive"
:class="holdPillClasses(isHeld(base))"
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="toggleHold(base)"
>
{{ baseLabel(base) }}
</button>
</div>
</div>
<!-- Confirm button: full-width, prominent (matches Roll Dice style) -->
<div class="flex justify-center">
<button
type="submit"
:disabled="!isActive || submitting"
:class="[
'px-8 py-4 rounded-lg font-bold text-lg transition-all duration-200 shadow-lg min-h-[60px] min-w-[200px] w-full',
isActive && !submitting
? 'bg-gradient-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 hover:shadow-xl active:scale-95'
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
]"
>
<span v-if="submitting" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Confirming...
</span>
<span v-else>Confirm Defense</span>
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { DefensiveDecision, GameState } from '~/types/game'
import ButtonGroup from '~/components/UI/ButtonGroup.vue'
import type { ButtonGroupOption } from '~/components/UI/ButtonGroup.vue'
import ActionButton from '~/components/UI/ActionButton.vue'
import type { DefensiveDecision } from '~/types/game'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
import { useGameStore } from '~/store/game'
interface Props {
gameId: string
isActive: boolean
currentSetup?: DefensiveDecision
gameState?: GameState
}
const props = withDefaults(defineProps<Props>(), {
@ -104,76 +112,80 @@ const emit = defineEmits<{
submit: [setup: DefensiveDecision]
}>()
const { infieldDepth, outfieldDepth, holdRunnersArray, getDecision, syncFromDecision } = useDefensiveSetup()
const gameStore = useGameStore()
const { infieldDepth, outfieldDepth, holdRunnersArray, isHeld, toggleHold, getDecision, syncFromDecision } = useDefensiveSetup()
// Local state
const submitting = ref(false)
// Dynamic options based on game state
const infieldDepthOptions = computed<ButtonGroupOption[]>(() => {
const options: ButtonGroupOption[] = [
{ value: 'normal', label: 'Normal', icon: '•' },
]
// Read game state from store instead of prop (fixes bug where gameState was never passed)
const storeGameState = computed(() => gameStore.gameState)
// Only show infield_in and corners_in if runner on third
if (props.gameState?.on_third) {
options.push({ value: 'infield_in', label: 'Infield In', icon: '⬆️' })
options.push({ value: 'corners_in', label: 'Corners In', icon: '◀️▶️' })
// Determine which bases are occupied
const occupiedBases = computed<number[]>(() => {
const gs = storeGameState.value
if (!gs) return []
const bases: number[] = []
if (gs.on_first) bases.push(1)
if (gs.on_second) bases.push(2)
if (gs.on_third) bases.push(3)
return bases
})
// Infield options: always show Normal; add IF In + Corners when runner on 3rd
const infieldOptions = computed(() => {
const options = [{ value: 'normal' as const, label: 'Normal' }]
if (storeGameState.value?.on_third) {
options.push({ value: 'infield_in' as const, label: 'IF In' })
options.push({ value: 'corners_in' as const, label: 'Corners' })
}
return options
})
const outfieldDepthOptions = computed<ButtonGroupOption[]>(() => {
const options: ButtonGroupOption[] = [
{ value: 'normal', label: 'Normal', icon: '•' },
]
// Outfield options: only show row when shallow is available (walk-off scenario)
const isWalkOffScenario = computed(() => {
const gs = storeGameState.value
if (!gs) return false
const isHomeBatting = gs.half === 'bottom'
const isLateInning = gs.inning >= 9
const isCloseGame = isHomeBatting
? gs.home_score <= gs.away_score
: gs.away_score <= gs.home_score
const hasRunners = gs.on_first || gs.on_second || gs.on_third
return isHomeBatting && isLateInning && isCloseGame && !!hasRunners
})
// Check for walk-off scenario
if (props.gameState) {
const { inning, half, home_score, away_score, on_first, on_second, on_third } = props.gameState
const isHomeBatting = half === 'bottom'
const isLateInning = inning >= 9
const isCloseGame = isHomeBatting
? home_score <= away_score
: away_score <= home_score
const hasRunners = on_first || on_second || on_third
const showOutfieldRow = computed(() => isWalkOffScenario.value)
// Only show shallow in walk-off situations
if (isHomeBatting && isLateInning && isCloseGame && hasRunners) {
options.push({ value: 'shallow', label: 'Shallow', icon: '⬇️' })
}
const outfieldOptions = computed(() => {
const options = [{ value: 'normal' as const, label: 'Normal' }]
if (isWalkOffScenario.value) {
options.push({ value: 'shallow' as const, label: 'Shallow' })
}
return options
})
// Display helpers
const infieldDisplay = computed(() => {
const option = infieldDepthOptions.value.find(opt => opt.value === infieldDepth.value)
return option?.label || 'Normal'
})
// Style helpers
const segmentClasses = (selected: boolean) => {
if (selected) {
return 'bg-gradient-to-r from-primary to-blue-600 text-white border-r border-blue-600 last:border-r-0'
}
return 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 border-r border-gray-300 dark:border-gray-600 last:border-r-0'
}
const outfieldDisplay = computed(() => {
const option = outfieldDepthOptions.value.find(opt => opt.value === outfieldDepth.value)
return option?.label || 'Normal'
})
const holdPillClasses = (held: boolean) => {
if (held) {
return 'border-blue-500 bg-gradient-to-r from-primary to-blue-600 text-white'
}
return 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}
const holdingDisplay = computed(() => {
const arr = holdRunnersArray.value
if (arr.length === 0) return 'None'
return arr.map(base => {
if (base === 1) return '1st'
if (base === 2) return '2nd'
if (base === 3) return '3rd'
return base
}).join(', ')
})
const submitButtonText = computed(() => {
if (!props.isActive) return 'Wait for Your Turn'
return 'Submit Defensive Setup'
})
const baseLabel = (base: number) => {
if (base === 1) return '1B'
if (base === 2) return '2B'
if (base === 3) return '3B'
return `${base}B`
}
// Handle form submission
const handleSubmit = async () => {

View File

@ -1,157 +1,281 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue";
import { useDefensiveSetup } from "~/composables/useDefensiveSetup";
import type { DefensiveDecision } from "~/types/game";
import { useGameStore } from "~/store/game";
import type { DefensiveDecision, GameState } from "~/types/game";
/**
* Creates a minimal GameState for testing, with sensible defaults.
* Only override the fields you care about per test.
*/
function makeGameState(overrides: Partial<GameState> = {}): GameState {
return {
game_id: "test-game-123",
league_id: "sba",
home_team_id: 1,
away_team_id: 2,
home_team_is_ai: false,
away_team_is_ai: false,
creator_discord_id: null,
auto_mode: false,
status: "active",
inning: 5,
half: "top",
outs: 0,
balls: 0,
strikes: 0,
home_score: 3,
away_score: 2,
on_first: null,
on_second: null,
on_third: null,
current_batter: { lineup_id: 10, card_id: 100, position: "LF", batting_order: 1, is_active: true },
current_pitcher: { lineup_id: 20, card_id: 200, position: "P", batting_order: null, is_active: true },
current_catcher: null,
current_on_base_code: 0,
away_team_batter_idx: 0,
home_team_batter_idx: 0,
pending_decision: null,
decision_phase: "awaiting_defensive",
decisions_this_play: {},
pending_defensive_decision: null,
pending_offensive_decision: null,
pending_manual_roll: null,
pending_x_check: null,
pending_uncapped_hit: null,
play_count: 0,
last_play_result: null,
created_at: "2025-01-01T00:00:00Z",
started_at: "2025-01-01T00:00:00Z",
completed_at: null,
...overrides,
} as GameState;
}
/** Helper to create a mock runner LineupPlayerState */
function makeRunner(lineupId: number, position: string = "LF") {
return { lineup_id: lineupId, card_id: lineupId * 10, position, batting_order: 1, is_active: true };
}
describe("DefensiveSetup", () => {
let pinia: ReturnType<typeof createPinia>;
const defaultProps = {
gameId: "test-game-123",
isActive: true,
};
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
// Reset the singleton composable state before each test
const { reset } = useDefensiveSetup();
reset();
});
/**
* Helper: mount with game state injected into the store.
* Sets up Pinia and populates gameStore.gameState before mounting.
*/
function mountWithGameState(gameStateOverrides: Partial<GameState> = {}, propsOverrides: Record<string, any> = {}) {
const gameStore = useGameStore();
gameStore.setGameState(makeGameState(gameStateOverrides));
return mount(DefensiveSetup, {
props: { ...defaultProps, ...propsOverrides },
global: { plugins: [pinia] },
});
}
describe("Rendering", () => {
it("renders component with header", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.text()).toContain("Defensive Setup");
it("renders component with compact header and glove emoji", () => {
/**
* The compact layout uses 🧤 emoji and "Defense" title (not the old
* "Defensive Setup" with 🛡).
*/
const wrapper = mountWithGameState();
expect(wrapper.text()).toContain("Defense");
expect(wrapper.text()).toContain("🧤");
expect(wrapper.text()).not.toContain("🛡️");
});
it("shows opponent turn indicator when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
expect(wrapper.text()).toContain("Opponent's Turn");
it("renders prominent full-width confirm button", () => {
/**
* The confirm button should be a large, full-width green button
* matching the Roll Dice button style.
*/
const wrapper = mountWithGameState();
expect(wrapper.text()).toContain("Confirm Defense");
});
it("renders all form sections", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.text()).toContain("Infield Depth");
expect(wrapper.text()).toContain("Outfield Depth");
expect(wrapper.text()).toContain("Current Setup");
it("does not render old preview box or ButtonGroup", () => {
/**
* The compact layout removes the "Current Setup" preview box
* and replaces ButtonGroup with inline segmented buttons.
*/
const wrapper = mountWithGameState();
expect(wrapper.text()).not.toContain("Current Setup");
expect(wrapper.text()).not.toContain("Infield Depth");
expect(wrapper.text()).not.toContain("Outfield Depth");
expect(wrapper.findAllComponents({ name: "ButtonGroup" })).toHaveLength(0);
});
});
describe("Initial Values", () => {
it("uses default values when no currentSetup provided", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Check preview shows defaults
it("always shows infield row with Normal option", () => {
/**
* Infield segmented control always renders with at least "Normal".
*/
const wrapper = mountWithGameState();
expect(wrapper.text()).toContain("Infield");
expect(wrapper.text()).toContain("Normal");
});
});
it("syncs composable from provided currentSetup via watcher", async () => {
describe("Infield Options (runner on 3rd)", () => {
it("shows only Normal when no runner on 3rd", () => {
/**
* When currentSetup prop is provided, the component should sync the
* composable state to match it. This verifies the prop->composable sync.
* When there's no runner on 3rd base, infield should only show "Normal".
* IF In and Corners should NOT appear.
*/
const currentSetup: DefensiveDecision = {
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
};
const wrapper = mountWithGameState({ on_third: null });
expect(wrapper.text()).toContain("Normal");
expect(wrapper.text()).not.toContain("IF In");
expect(wrapper.text()).not.toContain("Corners");
});
mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
});
it("shows all three infield options when runner on 3rd", () => {
/**
* When there IS a runner on 3rd, infield should show Normal, IF In, and Corners.
* This is driven by game state from the store (not a prop).
*/
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
expect(wrapper.text()).toContain("Normal");
expect(wrapper.text()).toContain("IF In");
expect(wrapper.text()).toContain("Corners");
});
// The composable should be synced from the prop via the watcher
const { holdRunnersArray, infieldDepth, outfieldDepth } =
useDefensiveSetup();
// Watcher fires on prop change, check initial sync happens
expect(infieldDepth.value).toBe("normal");
expect(outfieldDepth.value).toBe("normal");
it("can select infield_in when runner on 3rd", async () => {
/**
* Clicking the IF In button should update the composable's infieldDepth value.
*/
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
const buttons = wrapper.findAll('button[type="button"]');
const ifInBtn = buttons.find(b => b.text() === "IF In");
expect(ifInBtn).toBeTruthy();
await ifInBtn!.trigger("click");
const { infieldDepth } = useDefensiveSetup();
expect(infieldDepth.value).toBe("infield_in");
});
});
describe("Hold Runners Display", () => {
it('shows "None" when no runners held in preview', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
describe("Outfield Row (walk-off scenario)", () => {
it("hides outfield row in normal game situations", () => {
/**
* Outfield row should be hidden when it's NOT a walk-off scenario.
* A normal mid-game state should not show "Outfield" or "Shallow".
*/
const wrapper = mountWithGameState({
inning: 5,
half: "top",
on_first: makeRunner(10),
});
// Check preview section shows "None" for holding
expect(wrapper.text()).toContain("Holding:None");
expect(wrapper.text()).not.toContain("Outfield");
expect(wrapper.text()).not.toContain("Shallow");
});
it("displays holding status in preview for held runners", () => {
it("shows outfield row in walk-off scenario", () => {
/**
* The preview section should show a comma-separated list of held bases.
* Hold runner UI has moved to the runner pills themselves.
* Walk-off scenario: bottom of 9th+, home team losing/tied, runners on base.
* This should show the outfield row with Normal and Shallow options.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
const wrapper = mountWithGameState({
inning: 9,
half: "bottom",
home_score: 3,
away_score: 4,
on_third: makeRunner(30),
});
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Preview should show the held bases
expect(wrapper.text()).toContain("Holding:1st, 3rd");
expect(wrapper.text()).toContain("Outfield");
expect(wrapper.text()).toContain("Normal");
expect(wrapper.text()).toContain("Shallow");
});
it("displays holding status in preview for multiple runners", () => {
it("hides outfield row when home is winning in bottom of 9th", () => {
/**
* The preview section should show a comma-separated list of held bases.
* If home team is already ahead, it's not a walk-off scenario.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 2, 3],
const wrapper = mountWithGameState({
inning: 9,
half: "bottom",
home_score: 5,
away_score: 3,
on_first: makeRunner(10),
});
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
expect(wrapper.text()).not.toContain("Outfield");
expect(wrapper.text()).not.toContain("Shallow");
});
});
describe("Preview Display", () => {
it("displays current infield depth in preview", () => {
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "infield_in",
outfield_depth: "normal",
hold_runners: [],
describe("Hold Runner Toggles", () => {
it("shows hold pills only for occupied bases", () => {
/**
* Hold toggle pills should only appear for bases that have runners.
* Empty bases should not show a pill at all (not disabled, just absent).
*/
const wrapper = mountWithGameState({
on_first: makeRunner(10),
on_third: makeRunner(30),
});
expect(wrapper.text()).toContain("Hold");
expect(wrapper.text()).toContain("1B");
expect(wrapper.text()).toContain("3B");
expect(wrapper.text()).not.toContain("2B");
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
gameState: {
on_third: 123, // Need runner on third for infield_in option
} as any,
},
it("hides hold row when bases are empty", () => {
/**
* When no runners on base, the hold row shouldn't render at all.
*/
const wrapper = mountWithGameState({
on_first: null,
on_second: null,
on_third: null,
});
expect(wrapper.text()).not.toContain("Hold");
expect(wrapper.text()).not.toContain("1B");
expect(wrapper.text()).not.toContain("2B");
expect(wrapper.text()).not.toContain("3B");
});
expect(wrapper.text()).toContain("Infield In");
it("shows all three hold pills when bases loaded", () => {
const wrapper = mountWithGameState({
on_first: makeRunner(10),
on_second: makeRunner(20),
on_third: makeRunner(30),
});
expect(wrapper.text()).toContain("1B");
expect(wrapper.text()).toContain("2B");
expect(wrapper.text()).toContain("3B");
});
it("toggles hold state when pill is clicked", async () => {
/**
* Clicking a hold pill should toggle that base's hold state
* in the shared useDefensiveSetup composable.
*/
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
const holdBtn = wrapper.findAll('button[type="button"]').find(b => b.text() === "1B");
expect(holdBtn).toBeTruthy();
await holdBtn!.trigger("click");
const { isHeld } = useDefensiveSetup();
expect(isHeld(1)).toBe(true);
// Click again to toggle off
await holdBtn!.trigger("click");
expect(isHeld(1)).toBe(false);
});
});
@ -168,29 +292,19 @@ describe("DefensiveSetup", () => {
hold_runners: [2],
});
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
const wrapper = mountWithGameState();
await wrapper.find("form").trigger("submit.prevent");
expect(wrapper.emitted("submit")).toBeTruthy();
const emitted = wrapper.emitted(
"submit",
)![0][0] as DefensiveDecision;
const emitted = wrapper.emitted("submit")![0][0] as DefensiveDecision;
expect(emitted.infield_depth).toBe("normal");
expect(emitted.outfield_depth).toBe("normal");
expect(emitted.hold_runners).toEqual([2]);
});
it("does not submit when not active", async () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
const wrapper = mountWithGameState({}, { isActive: false });
await wrapper.find("form").trigger("submit.prevent");
expect(wrapper.emitted("submit")).toBeFalsy();
});
@ -200,53 +314,15 @@ describe("DefensiveSetup", () => {
* Submitting with defaults should emit a valid DefensiveDecision
* with normal depth and no held runners.
*/
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
const wrapper = mountWithGameState();
await wrapper.find("form").trigger("submit.prevent");
expect(wrapper.emitted("submit")).toBeTruthy();
const emitted = wrapper.emitted(
"submit",
)![0][0] as DefensiveDecision;
const emitted = wrapper.emitted("submit")![0][0] as DefensiveDecision;
expect(emitted.infield_depth).toBe("normal");
expect(emitted.outfield_depth).toBe("normal");
expect(emitted.hold_runners).toEqual([]);
});
it("shows loading state during submission", async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Trigger submission
wrapper.vm.submitting = true;
await wrapper.vm.$nextTick();
// Verify button is in loading state
expect(wrapper.vm.submitting).toBe(true);
});
});
describe("Submit Button State", () => {
it('shows "Wait for Your Turn" when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
expect(wrapper.vm.submitButtonText).toBe("Wait for Your Turn");
});
it('shows "Submit Defensive Setup" when active', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.vm.submitButtonText).toBe("Submit Defensive Setup");
});
});
describe("Prop Updates", () => {
@ -255,9 +331,7 @@ describe("DefensiveSetup", () => {
* When the parent updates the currentSetup prop (e.g. from server state),
* the composable should be synced to match.
*/
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
const wrapper = mountWithGameState();
const newSetup: DefensiveDecision = {
infield_depth: "infield_in",
@ -267,8 +341,7 @@ describe("DefensiveSetup", () => {
await wrapper.setProps({ currentSetup: newSetup });
const { infieldDepth, outfieldDepth, holdRunnersArray } =
useDefensiveSetup();
const { infieldDepth, outfieldDepth, holdRunnersArray } = useDefensiveSetup();
expect(infieldDepth.value).toBe("infield_in");
expect(outfieldDepth.value).toBe("normal");
expect(holdRunnersArray.value).toEqual([1, 2, 3]);
@ -276,20 +349,36 @@ describe("DefensiveSetup", () => {
});
describe("Disabled State", () => {
it("disables depth controls when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
const buttonGroups = wrapper.findAllComponents({
name: "ButtonGroup",
});
buttonGroups.forEach((bg) => {
expect(bg.props("disabled")).toBe(true);
it("disables all interactive buttons when not active", () => {
/**
* When isActive is false, all segmented control buttons and hold pills
* should be disabled.
*/
const wrapper = mountWithGameState(
{ on_first: makeRunner(10) },
{ isActive: false },
);
const buttons = wrapper.findAll('button[type="button"]');
buttons.forEach((btn) => {
expect(btn.attributes("disabled")).toBeDefined();
});
});
});
describe("Game State from Store (bug fix verification)", () => {
it("reads game state from store, not from props", () => {
/**
* The old DefensiveSetup used a gameState prop that was never passed by
* DecisionPanel, so conditional options (infield_in, corners_in, shallow)
* never appeared. The new version reads from useGameStore() directly.
*
* This test verifies the fix by setting store state with runner on 3rd
* WITHOUT passing any gameState prop, and expecting infield options to appear.
*/
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
// No gameState prop passed — component reads from store
expect(wrapper.text()).toContain("IF In");
expect(wrapper.text()).toContain("Corners");
});
});
});

View File

@ -0,0 +1,387 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Defensive Setup - Compact Design Mockups</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #111827; }
.mockup-card { max-width: 400px; margin: 0 auto; }
/* Simulate the primary blue gradient for selected buttons */
.btn-selected { background: linear-gradient(to right, #1e40af, #2563eb); }
</style>
</head>
<body class="text-gray-100 p-4">
<div class="max-w-lg mx-auto space-y-12">
<h1 class="text-2xl font-bold text-center text-white mb-2">Defensive Setup — Compact Mockups</h1>
<p class="text-sm text-gray-400 text-center mb-8">Mobile-first (400px max). Dark mode only for brevity.</p>
<!-- ============================================================ -->
<!-- SCENARIO: NO CHOICES (bases empty, normal game state) -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-yellow-400 mb-1">Scenario: No Choices Available</h2>
<p class="text-xs text-gray-400 mb-4">Bases empty, not a walk-off situation. Both infield and outfield only have "Normal".</p>
<div class="mockup-card space-y-4">
<h3 class="text-sm font-semibold text-gray-500">ALL DESIGNS → Component hidden entirely</h3>
<div class="bg-gray-800 rounded-xl p-4 text-center border border-dashed border-gray-600">
<p class="text-sm text-gray-500 italic">Nothing renders here. The defensive setup component is completely removed from the DOM.</p>
<p class="text-xs text-gray-600 mt-2">The DecisionPanel auto-submits default values (normal/normal/no holds) and skips to offensive phase.</p>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- SCENARIO: RUNNER ON 3RD ONLY (infield choices) -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-yellow-400 mb-1">Scenario: Runner on 3rd</h2>
<p class="text-xs text-gray-400 mb-6">Infield depth matters (3 options). Outfield still only "Normal". Hold runners available for R3.</p>
<!-- DESIGN A: Inline Segmented Control -->
<div class="mockup-card mb-8">
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design A — Inline Segmented Controls</h3>
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
<!-- Header row: icon + title + submit in one line -->
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white flex items-center gap-2">
🛡️ Defense
</h3>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
Confirm
</button>
</div>
<!-- Infield: label + horizontal pill buttons -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 border-r border-gray-600">Normal</button>
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">IF In</button>
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600">Corners</button>
</div>
</div>
<!-- Hold runners: inline toggle chips -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40 cursor-not-allowed" disabled>1B</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40 cursor-not-allowed" disabled>2B</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">3B</button>
</div>
</div>
</div>
</div>
<!-- DESIGN B: Single-Row Compact -->
<div class="mockup-card mb-8">
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design B — Ultra Compact (Single Card Row)</h3>
<div class="bg-gray-800 rounded-xl shadow-lg p-3 space-y-2">
<!-- Everything stacked tightly -->
<div class="flex items-center justify-between">
<span class="text-sm font-bold text-white">🛡️ Defense</span>
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md">Confirm</button>
</div>
<div class="flex gap-2">
<!-- Infield as compact segmented -->
<div class="flex-1">
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Infield</div>
<div class="flex rounded-md overflow-hidden border border-gray-600 text-xs">
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400 border-r border-gray-600">Norm</button>
<button class="flex-1 py-1.5 btn-selected text-white border-r border-gray-600">IF In</button>
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400">Corn</button>
</div>
</div>
<!-- Hold as compact chips -->
<div class="flex-shrink-0">
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Hold</div>
<div class="flex gap-1">
<button class="w-8 h-8 text-[10px] font-bold rounded-md border border-gray-600 bg-gray-700 text-gray-500 opacity-40" disabled>1B</button>
<button class="w-8 h-8 text-[10px] font-bold rounded-md border border-gray-600 bg-gray-700 text-gray-500 opacity-40" disabled>2B</button>
<button class="w-8 h-8 text-[10px] font-bold rounded-md border border-blue-500 btn-selected text-white">3B</button>
</div>
</div>
</div>
</div>
</div>
<!-- DESIGN C: Toolbar Style -->
<div class="mockup-card">
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design C — Toolbar Strip</h3>
<div class="bg-gray-800 rounded-xl shadow-lg overflow-hidden">
<!-- Single toolbar bar -->
<div class="flex items-center gap-3 px-3 py-2.5 border-b border-gray-700">
<span class="text-sm font-bold text-white flex-shrink-0">🛡️</span>
<!-- Infield segment -->
<div class="flex items-center gap-1.5">
<span class="text-[10px] text-gray-500 font-medium">IF:</span>
<div class="flex rounded-md overflow-hidden border border-gray-600">
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400 border-r border-gray-600">Norm</button>
<button class="px-2 py-1 text-[11px] btn-selected text-white border-r border-gray-600">In</button>
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400">Corn</button>
</div>
</div>
<!-- Divider -->
<div class="w-px h-5 bg-gray-700"></div>
<!-- Hold toggles -->
<div class="flex items-center gap-1.5">
<span class="text-[10px] text-gray-500 font-medium">Hold:</span>
<button class="w-6 h-6 text-[10px] font-bold rounded border border-blue-500 btn-selected text-white">3</button>
</div>
<!-- Spacer + confirm -->
<div class="flex-1"></div>
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md"></button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- SCENARIO: FULL OPTIONS (R1+R3, walk-off) -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-yellow-400 mb-1">Scenario: Full Options (R1 + R3, Walk-off)</h2>
<p class="text-xs text-gray-400 mb-6">All infield options + shallow outfield + hold runners on 1st and 3rd.</p>
<!-- DESIGN A: Inline Segmented Control -->
<div class="mockup-card mb-8">
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design A — Inline Segmented Controls</h3>
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white flex items-center gap-2">
🛡️ Defense
</h3>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
Confirm
</button>
</div>
<!-- Infield -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white border-r border-gray-600">Normal</button>
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 border-r border-gray-600">IF In</button>
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600">Corners</button>
</div>
</div>
<!-- Outfield -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Outfield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white border-r border-gray-600">Normal</button>
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600">Shallow</button>
</div>
</div>
<!-- Hold runners -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40 cursor-not-allowed" disabled>2B</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">3B</button>
</div>
</div>
</div>
</div>
<!-- DESIGN B: Ultra Compact -->
<div class="mockup-card mb-8">
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design B — Ultra Compact</h3>
<div class="bg-gray-800 rounded-xl shadow-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-bold text-white">🛡️ Defense</span>
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md">Confirm</button>
</div>
<div class="grid grid-cols-2 gap-2">
<!-- Infield -->
<div>
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Infield</div>
<div class="flex rounded-md overflow-hidden border border-gray-600 text-xs">
<button class="flex-1 py-1.5 btn-selected text-white border-r border-gray-600">Norm</button>
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400 border-r border-gray-600">In</button>
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400">Corn</button>
</div>
</div>
<!-- Outfield -->
<div>
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Outfield</div>
<div class="flex rounded-md overflow-hidden border border-gray-600 text-xs">
<button class="flex-1 py-1.5 btn-selected text-white border-r border-gray-600">Norm</button>
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400">Shallow</button>
</div>
</div>
</div>
<!-- Hold runners - full width row below -->
<div class="flex items-center gap-2">
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">Hold</div>
<div class="flex gap-1">
<button class="w-8 h-7 text-[10px] font-bold rounded-md border border-blue-500 btn-selected text-white">1B</button>
<button class="w-8 h-7 text-[10px] font-bold rounded-md border border-gray-600 bg-gray-700 text-gray-500 opacity-40" disabled>2B</button>
<button class="w-8 h-7 text-[10px] font-bold rounded-md border border-blue-500 btn-selected text-white">3B</button>
</div>
</div>
</div>
</div>
<!-- DESIGN C: Toolbar Strip -->
<div class="mockup-card">
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design C — Toolbar Strip</h3>
<div class="bg-gray-800 rounded-xl shadow-lg overflow-hidden">
<!-- Row 1: Infield + Outfield -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-700">
<span class="text-sm font-bold text-white flex-shrink-0">🛡️</span>
<div class="flex items-center gap-1.5">
<span class="text-[10px] text-gray-500 font-medium">IF:</span>
<div class="flex rounded-md overflow-hidden border border-gray-600">
<button class="px-2 py-1 text-[11px] btn-selected text-white border-r border-gray-600">Norm</button>
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400 border-r border-gray-600">In</button>
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400">Corn</button>
</div>
</div>
<div class="w-px h-5 bg-gray-700"></div>
<div class="flex items-center gap-1.5">
<span class="text-[10px] text-gray-500 font-medium">OF:</span>
<div class="flex rounded-md overflow-hidden border border-gray-600">
<button class="px-2 py-1 text-[11px] btn-selected text-white border-r border-gray-600">Norm</button>
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400">Shal</button>
</div>
</div>
<div class="w-px h-5 bg-gray-700"></div>
<div class="flex items-center gap-1.5">
<span class="text-[10px] text-gray-500 font-medium">Hold:</span>
<button class="w-5 h-5 text-[9px] font-bold rounded border border-blue-500 btn-selected text-white">1</button>
<button class="w-5 h-5 text-[9px] font-bold rounded border border-blue-500 btn-selected text-white">3</button>
</div>
<div class="flex-1"></div>
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md"></button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- SIDE-BY-SIDE SIZE COMPARISON -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-yellow-400 mb-1">Size Comparison: Current vs Design A</h2>
<p class="text-xs text-gray-400 mb-6">Same scenario (R3 only) showing height saved.</p>
<div class="grid grid-cols-2 gap-4">
<!-- CURRENT -->
<div>
<h3 class="text-xs font-semibold text-red-400 mb-2 text-center">CURRENT (~340px tall)</h3>
<div class="bg-gray-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span class="text-xl">🛡️</span> Defensive Setup
</h3>
</div>
<div class="space-y-6">
<div>
<label class="block text-sm font-semibold text-gray-300 mb-3">Infield Depth</label>
<div class="flex flex-col w-full">
<button class="w-full py-2.5 text-sm btn-selected text-white rounded-t-lg border border-blue-600">• Normal</button>
<button class="w-full py-2.5 text-sm bg-white text-gray-700 border border-gray-300">⬆️ Infield In</button>
<button class="w-full py-2.5 text-sm bg-white text-gray-700 border border-gray-300 rounded-b-lg">◀️▶️ Corners In</button>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-300 mb-3">Outfield Depth</label>
<div class="flex">
<button class="flex-1 py-2.5 text-sm btn-selected text-white rounded-lg border border-blue-600">• Normal</button>
</div>
</div>
<div class="bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-300 mb-2">Current Setup</h4>
<div class="grid grid-cols-2 gap-2 text-xs">
<div><span class="text-gray-400">Infield:</span> <span class="text-white">Normal</span></div>
<div><span class="text-gray-400">Outfield:</span> <span class="text-white">Normal</span></div>
<div class="col-span-2"><span class="text-gray-400">Holding:</span> <span class="text-white">None</span></div>
</div>
</div>
<button class="w-full py-3 text-base font-semibold bg-green-600 text-white rounded-lg">Submit Defensive Setup</button>
</div>
</div>
</div>
<!-- DESIGN A -->
<div>
<h3 class="text-xs font-semibold text-emerald-400 mb-2 text-center">DESIGN A (~120px tall)</h3>
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white flex items-center gap-2">🛡️ Defense</h3>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white border-r border-gray-600">Normal</button>
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 border-r border-gray-600">IF In</button>
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300">Corners</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40" disabled>1B</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40" disabled>2B</button>
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">3B</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- RECOMMENDATION -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6 pb-12">
<h2 class="text-lg font-bold text-white mb-4">Recommendation</h2>
<div class="bg-gray-800 rounded-xl p-4 space-y-3 text-sm text-gray-300">
<p><strong class="text-emerald-400">Design A (Inline Segmented)</strong> is the best balance of compactness and usability:</p>
<ul class="list-disc list-inside space-y-1 text-xs text-gray-400">
<li>~65% height reduction vs current design</li>
<li>All options visible at a glance — no scrolling needed</li>
<li>Touch targets still meet 44px minimum on the segmented buttons</li>
<li>Labels (Infield/Outfield/Hold) provide clear context without verbose headers</li>
<li>Confirm button in the header row saves a full row of vertical space</li>
<li>Removes redundant "Current Setup" preview — the buttons <em>are</em> the preview</li>
<li>Horizontal layout works well on mobile portrait (400px fits 3 segments comfortably)</li>
</ul>
<p class="text-xs text-gray-500 mt-3"><strong>Design B</strong> is even more compact but abbreviates labels (Norm/In/Corn) which hurts readability.<br>
<strong>Design C</strong> is the most compact but gets cramped in the full-options scenario and doesn't scale well.</p>
<div class="mt-4 p-3 bg-gray-700/50 rounded-lg">
<p class="text-xs font-semibold text-yellow-400 mb-1">Auto-hide logic:</p>
<p class="text-xs text-gray-400">When <code class="bg-gray-700 px-1 rounded">hasDefensiveChoices</code> is false (no runner on 3rd AND not walk-off), auto-submit defaults and skip to offensive phase. The component never renders.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Turn Indicator — Design Options</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #111827; }
.mockup-card { max-width: 400px; margin: 0 auto; }
.btn-selected { background: linear-gradient(to right, #1e40af, #2563eb); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulse-dot { animation: pulse-dot 1.5s ease-in-out infinite; }
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.shimmer-border {
background: linear-gradient(90deg, #22c55e, #86efac, #22c55e);
background-size: 200% 100%;
animation: shimmer 2s linear infinite;
}
</style>
</head>
<body class="text-gray-100 p-4">
<div class="max-w-lg mx-auto space-y-12">
<h1 class="text-2xl font-bold text-center text-white mb-2">Turn Indicator — Design Options</h1>
<p class="text-sm text-gray-400 text-center mb-8">Alternatives to the big green "Your Defensive Turn" banner. All shown with the compact Defense card below.</p>
<!-- ============================================================ -->
<!-- CURRENT: Big green banner (for comparison) -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-red-400 mb-1">Current Design</h2>
<p class="text-xs text-gray-400 mb-4">~80px green banner above the card. Redundant — the card already implies it's your turn.</p>
<div class="mockup-card space-y-4">
<!-- Turn indicator banner -->
<div class="rounded-xl shadow-lg p-4 text-center bg-gradient-to-r from-green-600 to-green-700 text-white">
<div class="flex items-center justify-center gap-3">
<span class="text-3xl"></span>
<div>
<h2 class="text-xl font-bold">Your Defensive Turn</h2>
<p class="text-sm opacity-90 mt-0.5">Set your defensive positioning and strategy</p>
</div>
</div>
</div>
<!-- Defense card -->
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
<div class="flex items-center">
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
</div>
<div class="flex-1"></div>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- OPTION A: No separate indicator — green left accent border -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option A: Green Left Accent</h2>
<p class="text-xs text-gray-400 mb-4">No separate banner. A green left border on the card itself signals "action needed". Saves ~84px of vertical space.</p>
<div class="mockup-card">
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3 border-l-4 border-green-500">
<div class="flex items-center">
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
</div>
<div class="flex-1"></div>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- OPTION B: Pulsing dot + "Your Turn" badge in card header -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option B: Pulsing Dot + Badge</h2>
<p class="text-xs text-gray-400 mb-4">A small pulsing green dot and "Your Turn" pill badge integrated into the card header. Draws attention without a full banner.</p>
<div class="mockup-card">
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<span class="relative flex h-2.5 w-2.5">
<span class="pulse-dot absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
</span>
🧤 Defense
</h3>
<span class="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-green-500/20 text-green-400 uppercase tracking-wider">Your Turn</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
</div>
<div class="flex-1"></div>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- OPTION C: Shimmer top border accent -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option C: Animated Top Border</h2>
<p class="text-xs text-gray-400 mb-4">A thin animated green shimmer along the top edge of the card. Subtle but eye-catching. No extra text.</p>
<div class="mockup-card">
<div class="rounded-xl overflow-hidden shadow-lg">
<!-- Shimmer border strip -->
<div class="h-1 shimmer-border"></div>
<div class="bg-gray-800 p-4 space-y-3">
<div class="flex items-center">
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
</div>
<div class="flex-1"></div>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- OPTION D: Accent border + badge (A + B combined) -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option D: Left Accent + Badge</h2>
<p class="text-xs text-gray-400 mb-4">Combines the green left border with the "Your Turn" badge. Maximum clarity without a separate component.</p>
<div class="mockup-card">
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3 border-l-4 border-green-500">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
<span class="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-green-500/20 text-green-400 uppercase tracking-wider">Your Turn</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
</div>
<div class="flex-1"></div>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- OPTION E: Glowing ring -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option E: Glowing Ring</h2>
<p class="text-xs text-gray-400 mb-4">The entire card gets a subtle green glow/ring. Clearly "active" vs the other cards on screen without any extra text or elements.</p>
<div class="mockup-card">
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3 ring-2 ring-green-500/60 shadow-green-500/20 shadow-lg">
<div class="flex items-center">
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
<div class="flex gap-1.5">
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
</div>
<div class="flex-1"></div>
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
</div>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- COMPARISON: Opponent's turn / Waiting state -->
<!-- ============================================================ -->
<div class="border-t border-gray-700 pt-6 pb-12">
<h2 class="text-lg font-bold text-yellow-400 mb-1">Contrast: Opponent's Turn</h2>
<p class="text-xs text-gray-400 mb-4">For comparison — what the waiting state looks like when it's NOT your turn. The contrast makes the green treatments above stand out.</p>
<div class="mockup-card">
<div class="bg-gray-800 rounded-xl shadow-lg p-8 text-center opacity-70">
<div class="text-4xl mb-3"></div>
<h3 class="text-sm font-semibold text-gray-400">Waiting for Opponent</h3>
</div>
</div>
</div>
</div>
</body>
</html>