Fix some card UX.

This commit is contained in:
Greyson Parrelli
2026-07-27 00:03:23 -04:00
parent 76e08ceb17
commit 1070999b95
4 changed files with 133 additions and 15 deletions
+21 -2
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
import { CardView, CardZoom } from './CardView'
import { useCardAnimations } from '../anim'
import { useMediaQuery } from '../useMediaQuery'
interface Props {
view: GameView
@@ -37,6 +38,11 @@ export function ArrangePhase({ view, you, send }: Props) {
// drag) always calls the fresh closure — see the drag effect below.
const moveRef = useRef<(clientY: number) => void>(() => {})
const locked = you.ready
// Phones can't hover to preview a card, so a tap opens the magnified view
// instead. Dragging uses the grip handle, so tapping the card body is free to
// mean "read it". Ignored on desktop, which keeps the hover magnifier.
const isPhone = useMediaQuery('(max-width: 600px)')
const [zoom, setZoom] = useState<{ card: Card; bonus: number } | null>(null)
// The arrow buttons reorder `order` and the cards glide to their new slot.
// A drag reorders live too, and the neighbors the dragged card passes should
@@ -250,7 +256,16 @@ export function ArrangePhase({ view, you, send }: Props) {
</button>
</div>
</div>
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
<CardView
card={c}
bonus={bonuses.get(c.id) ?? 0}
noMagnify={dragging}
onClick={
isPhone && !dragging
? () => setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 })
: undefined
}
/>
</div>
</div>
)
@@ -272,6 +287,10 @@ export function ArrangePhase({ view, you, send }: Props) {
Lock in & battle
</button>
</div>
{zoom && (
<CardZoom card={zoom.card} bonus={zoom.bonus} onClose={() => setZoom(null)} />
)}
</div>
)
}
+48 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView'
import { CardView, CardZoom } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll'
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
import { useMediaQuery } from '../useMediaQuery'
// How long a settled rock roll (and its damage) stays on screen before the
// battle advances to the next step.
@@ -313,6 +314,25 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// paused freezes auto-advance so the player can walk the log manually.
const [paused, setPaused] = useState(false)
// Phone tap-to-magnify: tapping a pet/food opens a big readable copy (phones
// can't hover to preview). Opening it pauses the replay; closing it resumes
// only if the replay was actually playing when we tapped, so a manual pause
// (or a finished battle) stays put.
const isPhone = useMediaQuery('(max-width: 600px)')
const [zoom, setZoom] = useState<{ card: Card; bonus: number; damage: number; dead: boolean } | null>(
null,
)
const resumeAfterZoom = useRef(false)
const openZoom = (z: { card: Card; bonus?: number; damage?: number; dead?: boolean }) => {
resumeAfterZoom.current = !paused && !done
setPaused(true)
setZoom({ card: z.card, bonus: z.bonus ?? 0, damage: z.damage ?? 0, dead: !!z.dead })
}
const closeZoom = () => {
setZoom(null)
if (resumeAfterZoom.current) setPaused(false)
}
// A rock event on screen scrambles its dice first; only once they settle do
// we apply the damage and reveal the result.
const showingRock = !done && lastEvent?.type === 'rock'
@@ -485,7 +505,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
}`}
style={{ zIndex: i + 1 }}
>
<CardView card={f} size="sm" />
<CardView
card={f}
size="sm"
onClick={isPhone ? () => openZoom({ card: f }) : undefined}
/>
</div>
))}
</div>
@@ -512,6 +536,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
bonus={s.unit.bonus}
damage={s.unit.damage}
dead={s.unit.dying}
onClick={
isPhone
? () =>
openZoom({
card: s.unit!.card,
bonus: s.unit!.bonus,
damage: s.unit!.damage,
dead: s.unit!.dying,
})
: undefined
}
/>
{(s.unit.spooked > 0 || s.unit.exposed > 0) && (
<div className="ailment-badges">
@@ -717,6 +752,16 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>,
document.body,
)}
{zoom && (
<CardZoom
card={zoom.card}
bonus={zoom.bonus}
damage={zoom.damage}
dead={zoom.dead}
onClose={closeZoom}
/>
)}
</div>
)
}
+50 -8
View File
@@ -177,6 +177,39 @@ export function CardMagnify({
)
}
// CardZoom is the touch-friendly "tap to read" overlay: a big centered copy of
// a card with a Close button, portaled over everything. Phones have no hover to
// pop the magnifier, so a tap opens this instead (the shop has its own richer
// version with Buy/Sell). Battle decorations (buffs, damage, death) ride along
// so the zoomed card matches what's on the board.
export function CardZoom({
card,
bonus = 0,
damage = 0,
dead,
onClose,
}: {
card: Card
bonus?: number
damage?: number
dead?: boolean
onClose: () => void
}) {
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div className="card-focus" onClick={(e) => e.stopPropagation()}>
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
<div className="card-focus-actions">
<button className="btn btn-ghost" onClick={onClose}>
Close
</button>
</div>
</div>
</div>,
document.body,
)
}
interface Props {
card: Card
size?: 'sm' | 'md' | 'lg'
@@ -265,18 +298,27 @@ export function CardView({
</div>
</div>
{/* Lower panel: the suit hat, name, and tier die on one row, the ability
beneath — a white card in a rounded green frame. */}
beneath — a white card in a rounded green frame. Token cards (apples,
ailments) have neither a suit nor a tier, so their name takes the whole
row instead of being squeezed between two empty spacer slots (which, on
a small battle card, left "Apple" only a few pixels and clipped it). */}
<div className="card-panel">
<div className="card-panel-head">
{card.suit ? (
<span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden />
) : (
<span className="card-panel-slot" aria-hidden />
)}
<div className={`card-panel-head ${!card.suit && !card.tier ? 'is-bare' : ''}`}>
{(card.suit || card.tier) &&
(card.suit ? (
<span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden />
) : (
<span className="card-panel-slot" aria-hidden />
))}
<span className="card-name" ref={nameRef}>
{card.name}
</span>
{card.tier ? <TierDie tier={card.tier} /> : <span className="card-panel-slot" aria-hidden />}
{(card.suit || card.tier) &&
(card.tier ? (
<TierDie tier={card.tier} />
) : (
<span className="card-panel-slot" aria-hidden />
))}
</div>
<div className="card-effect" ref={effectRef}>
{card.effectText
+14 -2
View File
@@ -1237,6 +1237,14 @@ h3 {
padding-bottom: 2px;
}
/* Token cards (apples, ailments) carry no suit dot or tier die, so their name
spans the whole row instead of being pinched between two empty spacer slots —
on a small battle card that pinch left only a few pixels and clipped the
title down to "A". */
.card-panel-head.is-bare {
grid-template-columns: minmax(0, 1fr);
}
/* The suit marker: a plain enamel dot (see .suit-dot), sized to sit opposite
the tier die in the name row. */
.card-suit {
@@ -1402,10 +1410,14 @@ h3 {
font-size: 0.74rem;
}
/* Damage marker sits at the bottom-centre of the illustration so it never
overlaps the power badge (top-centre) — on a narrow battle card the old
top-right spot rode on top of the power and hid it. */
.card-damage {
position: absolute;
top: 5%;
right: 5%;
bottom: 4%;
left: 50%;
transform: translateX(-50%);
z-index: 3;
font-family: var(--font-display);
color: #fff;