diff --git a/web/src/App.css b/web/src/App.css index cdf4fb1..e1007ac 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -380,6 +380,12 @@ color: var(--ink-60); } +.action-desc-line { + font-size: 12px; + color: var(--ink-70); + line-height: 1.3; +} + .action-buttons { display: flex; gap: 10px; @@ -391,6 +397,29 @@ flex-wrap: wrap; } +.action-button { + display: grid; + gap: 4px; + text-align: left; + align-items: start; + padding: 10px 14px; + white-space: normal; + max-width: 260px; + border-radius: 16px; +} + +.action-button .action-label { + display: block; +} + +.action-button .action-desc { + font-size: 11px; + color: var(--ink-60); + text-transform: none; + letter-spacing: 0.2px; + line-height: 1.3; +} + .action-choice-row .selected-action { border-color: rgba(224, 164, 99, 0.9); background: rgba(224, 164, 99, 0.2); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7f142fc..31348ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -334,6 +334,11 @@ function App() {
{selectedAction?.label ?? 'Select an action'}
+ {selectedAction && actionDescription(selectedAction) ? ( +
+ {actionDescription(selectedAction)} +
+ ) : null}
{state?.resolving_one_off ? 'Resolve or counter the one-off.' @@ -364,16 +369,14 @@ function App() {
{actionChoices.map((action) => ( - + onClick={() => handleActionSelect(action)} + /> ))}
@@ -522,17 +525,13 @@ function App() {
{(sevenActionsByCard.get(card.id) ?? []).map((action) => ( - + /> ))}
@@ -578,15 +577,13 @@ function App() {
{modalActions.map((action) => ( - + /> ))}
@@ -619,8 +616,33 @@ type CardTileProps = { onClick?: () => void } +type ActionButtonProps = { + action: ActionView + context: string + selected?: boolean + disabled?: boolean + onClick: () => void +} + const oneOffRanks = new Set(['ACE', 'THREE', 'FOUR', 'FIVE', 'SIX']) const faceRanks = new Set(['JACK', 'QUEEN', 'KING', 'EIGHT']) +const oneOffDescriptions: Record = { + ACE: 'Destroy all point cards on the field.', + TWO: 'Scrap a target royal or Glasses Eight.', + THREE: 'Take one card from the scrap pile.', + FOUR: 'Opponent discards two cards of their choice.', + FIVE: 'Discard one card, then draw up to three (max 8 in hand).', + SIX: 'Destroy all face cards (royals) on the field.', + SEVEN: 'Reveal the top two cards of the deck; choose one to play.', + NINE: "Return an opponent's field card to their hand (cannot play it next turn).", + TEN: 'No effect.', +} +const faceCardDescriptions: Record = { + KING: 'Reduce points needed to win while in play.', + QUEEN: 'Protect your other cards from targeted effects.', + JACK: 'Steal a target point card while in play.', + EIGHT: "Reveal your opponent's hand while in play.", +} function cardTag(card: CardView) { if (card.purpose === 'POINTS') return 'points' @@ -650,6 +672,61 @@ function rankShort(rank: string) { return map[rank] ?? rank } +function actionDescription(action: ActionView): string | null { + if (action.type === 'Draw') { + return 'Draw one card (max 8 in hand).' + } + if (action.type === 'Points') { + return 'Play for points equal to its rank.' + } + if (action.type === 'Scuttle') { + return 'Scrap an opponent point card with a higher card (suit breaks ties).' + } + if (action.type === 'Face Card') { + if (action.card?.rank && faceCardDescriptions[action.card.rank]) { + return faceCardDescriptions[action.card.rank] + } + return 'Play a royal for an ongoing effect.' + } + if (action.type === 'Jack') { + return 'Steal a target point card while in play.' + } + if (action.type === 'One-Off') { + if (action.card?.rank && oneOffDescriptions[action.card.rank]) { + return oneOffDescriptions[action.card.rank] + } + return "Trigger this card's one-off effect." + } + if (action.type === 'Counter') { + return 'Counter a one-off with a Two.' + } + if (action.type === 'Resolve') { + return 'Let the pending one-off resolve.' + } + if (action.type === 'Take From Discard') { + return 'Take one card from the scrap pile.' + } + if (action.type === 'Discard From Hand') { + return 'Discard a card from your hand.' + } + if (action.type === 'Discard Revealed') { + return 'Discard a revealed card.' + } + if (action.type === 'Request Stalemate') { + return 'Ask to end the game in a stalemate.' + } + if (action.type === 'Accept Stalemate') { + return 'Accept the stalemate request.' + } + if (action.type === 'Reject Stalemate') { + return 'Reject the stalemate request.' + } + if (action.type === 'Concede') { + return 'Concede the game.' + } + return null +} + function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) { const suitProps = { size, strokeWidth: 2.5 } @@ -667,6 +744,34 @@ function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) { } } +function ActionButton({ + action, + context, + selected, + disabled, + onClick, +}: ActionButtonProps) { + const description = actionDescription(action) + const descId = description ? `action-desc-${context}-${action.id}` : undefined + + return ( + + ) +} + function CardTile({ card, selected, isHand, onClick }: CardTileProps) { const tag = cardTag(card) const rank = rankShort(card.rank)