Fix mobile: compress card fan to fit any screen; kill horizontal overflow

- PlayerHand measures viewport and compresses the fan (responsive card size +
  dynamic overlap) so all 13 cards are visible and tappable on phones — no
  off-screen cards, no horizontal scroll; added tap feedback
- globals: html/body overflow-x hidden + overscroll-behavior none

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 16:00:00 +03:30
parent 2d2352dfe8
commit 0b376aec16
2 changed files with 38 additions and 12 deletions
+2
View File
@@ -42,6 +42,8 @@
html, html,
body { body {
height: 100%; height: 100%;
overflow-x: hidden;
overscroll-behavior: none;
} }
body { body {
+36 -12
View File
@@ -354,12 +354,28 @@ function TrickArea({
/* ----------------------------- Player hand ---------------------------- */ /* ----------------------------- Player hand ---------------------------- */
function useViewportWidth() {
const [vw, setVw] = useState(typeof window !== "undefined" ? window.innerWidth : 390);
useEffect(() => {
const f = () => setVw(window.innerWidth);
f();
window.addEventListener("resize", f);
window.addEventListener("orientationchange", f);
return () => {
window.removeEventListener("resize", f);
window.removeEventListener("orientationchange", f);
};
}, []);
return vw;
}
function PlayerHand({ legalIds }: { legalIds: Set<string> }) { function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const hand = useGameStore((s) => s.game.players[0].hand); const hand = useGameStore((s) => s.game.players[0].hand);
const phase = useGameStore((s) => s.game.phase); const phase = useGameStore((s) => s.game.phase);
const turn = useGameStore((s) => s.game.turn); const turn = useGameStore((s) => s.game.turn);
const playHuman = useGameStore((s) => s.playHuman); const playHuman = useGameStore((s) => s.playHuman);
const { front } = useCardSkins(); const { front } = useCardSkins();
const vw = useViewportWidth();
const sorted = sortHand(hand); const sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0; const myTurn = phase === "playing" && turn === 0;
@@ -367,45 +383,53 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const choosing = phase === "choosing-trump"; const choosing = phase === "choosing-trump";
const n = sorted.length; const n = sorted.length;
// Compress the fan so every card fits the screen width (no overflow/scroll).
const small = vw < 560;
const size = vw < 360 ? "sm" : vw < 560 ? "md" : "lg";
const cardW = size === "sm" ? 44 : size === "md" ? 60 : 74;
const avail = Math.min(vw - 12, 620);
const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0;
const overlap = step - cardW; // negative inline-start margin
return ( return (
<div <div
className={cn( className={cn(
"absolute bottom-0 inset-x-0 flex justify-center pb-3 pointer-events-none", "absolute bottom-0 inset-x-0 flex justify-center pb-2 pointer-events-none",
choosing ? "z-50" : "z-20" choosing ? "z-50" : "z-20"
)} )}
> >
<div className="relative flex items-end justify-center pointer-events-auto"> <div className="relative flex items-end justify-center pointer-events-auto max-w-full">
{sorted.map((card, i) => { {sorted.map((card, i) => {
const playable = myTurn && legalIds.has(card.id); const playable = myTurn && legalIds.has(card.id);
const dimmed = myTurn && !playable; const dimmed = myTurn && !playable;
const mid = (n - 1) / 2; const mid = (n - 1) / 2;
const rot = (i - mid) * 3.2; const rot = (i - mid) * (small ? 2 : 3.2);
const lift = Math.abs(i - mid) * 4; const lift = Math.abs(i - mid) * (small ? 2 : 4);
return ( return (
<motion.button <motion.button
key={card.id} key={card.id}
layout layout
initial={{ y: 120, opacity: 0 }} initial={{ y: 120, opacity: 0 }}
animate={{ y: lift, opacity: 1, rotate: rot }} animate={{ y: lift, opacity: 1, rotate: rot }}
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.015 }} transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.012 }}
whileHover={playable ? { y: lift - 26, scale: 1.06, zIndex: 50 } : {}} whileHover={playable ? { y: lift - 26, scale: 1.08, zIndex: 50 } : {}}
whileTap={playable ? { scale: 1.05 } : {}}
onClick={() => playable && playHuman(card)} onClick={() => playable && playHuman(card)}
disabled={!playable} disabled={!playable}
data-card={card.id} data-card={card.id}
data-playable={playable ? "1" : "0"} data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : -22 }} style={{ marginInlineStart: i === 0 ? 0 : overlap }}
className={cn( className={cn(
"origin-bottom", "origin-bottom shrink-0",
playable && "cursor-pointer", playable ? "cursor-pointer relative z-30" : "cursor-default"
!myTurn && "cursor-default"
)} )}
> >
<PlayingCard <PlayingCard
card={card} card={card}
size="lg" size={size}
dimmed={dimmed} dimmed={dimmed}
front={front} front={front}
className={cn(playable && "ring-2 ring-gold-400/70")} className={cn(playable && "ring-2 ring-gold-400/80")}
/> />
</motion.button> </motion.button>
); );