ui: unified rounded navbar everywhere, vertical home actions, no bot disconnect spam
- NavRail: one rounded "pill" tab bar on every screen (matches home). ScreenShell lays out as a portrait column and floats the nav with margins + safe-area; dropped the landscape side-rail variant. - Home: the three mode cards now stack vertically as full-width rows (portrait friendly) instead of a 3-up landscape row. - Disconnect: removed the simulated random opponent "disconnect" in local games (DISCONNECT_CHANCE) and the in-game DisconnectBanner — bots/filled seats just auto-play their turn; no message, no pause. (Live reconnect grace still tracked internally but no longer shows a banner.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react";
|
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
import { useSoundStore } from "@/lib/sound-store";
|
import { useSoundStore } from "@/lib/sound-store";
|
||||||
@@ -210,7 +210,6 @@ export function GameTable({
|
|||||||
<TurnTimer />
|
<TurnTimer />
|
||||||
<TurnIndicator />
|
<TurnIndicator />
|
||||||
</div>
|
</div>
|
||||||
<DisconnectBanner />
|
|
||||||
<Reactions />
|
<Reactions />
|
||||||
<ShortcutsHint />
|
<ShortcutsHint />
|
||||||
|
|
||||||
@@ -734,33 +733,6 @@ function TurnTimer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DisconnectBanner() {
|
|
||||||
const seat = useGameStore((s) => s.disconnectedSeat);
|
|
||||||
const deadline = useGameStore((s) => s.reconnectDeadline);
|
|
||||||
const name = useGameStore((s) => (seat != null ? s.seatPlayers[seat]?.name : null));
|
|
||||||
const secs = useCountdown(deadline);
|
|
||||||
const { t } = useI18n();
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{seat != null && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-40"
|
|
||||||
>
|
|
||||||
<div className="glass rounded-2xl px-4 py-3 flex items-center gap-2.5 text-sm">
|
|
||||||
<WifiOff className="size-5 text-rose-300 animate-pulse" />
|
|
||||||
<span className="text-cream/90">
|
|
||||||
{t("dc.waiting", { name: name ?? "", s: secs ?? 0 })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- Reactions ------------------------------ */
|
/* ----------------------------- Reactions ------------------------------ */
|
||||||
|
|
||||||
const REACTION_POS: Record<number, string> = {
|
const REACTION_POS: Record<number, string> = {
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ export function HomeScreen() {
|
|||||||
<span className="text-cream/60 align-middle mr-2 text-xs sm:text-sm">{t("app.subtitle")}</span>
|
<span className="text-cream/60 align-middle mr-2 text-xs sm:text-sm">{t("app.subtitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* mode cards */}
|
{/* mode cards — stacked vertically for portrait */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 14 }}
|
initial={{ opacity: 0, y: 14 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex-1 min-h-0 flex items-center justify-center gap-3 sm:gap-4 short:gap-2"
|
className="flex-1 min-h-0 w-full max-w-md mx-auto flex flex-col justify-center gap-3 short:gap-2"
|
||||||
>
|
>
|
||||||
<ModeCard
|
<ModeCard
|
||||||
tone="gold"
|
tone="gold"
|
||||||
@@ -211,23 +211,25 @@ function ModeCard({
|
|||||||
color: c.fg,
|
color: c.fg,
|
||||||
boxShadow: `0 7px 0 ${c.lo}, 0 12px 20px rgba(0,0,0,.32), inset 0 2px 0 rgba(255,255,255,.4)`,
|
boxShadow: `0 7px 0 ${c.lo}, 0 12px 20px rgba(0,0,0,.32), inset 0 2px 0 rgba(255,255,255,.4)`,
|
||||||
}}
|
}}
|
||||||
className="relative flex-1 max-w-[220px] sm:max-w-[260px] min-h-[200px] sm:min-h-[230px] short:min-h-[140px] rounded-3xl px-3 py-5 short:py-3 flex flex-col items-center justify-center gap-2.5 short:gap-1.5 text-center"
|
className="relative w-full rounded-3xl px-4 py-4 short:py-3 flex items-center gap-4 text-start"
|
||||||
>
|
>
|
||||||
{badge && (
|
|
||||||
<span className="absolute top-2 ltr:right-2 rtl:left-2 rounded-full bg-rose-500 px-2 py-0.5 text-[10px] font-bold text-white shadow">
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className="grid size-14 short:size-11 place-items-center rounded-2xl"
|
className="grid size-14 short:size-12 place-items-center rounded-2xl shrink-0"
|
||||||
style={{ background: "rgba(255,255,255,.22)" }}
|
style={{ background: "rgba(255,255,255,.22)" }}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base short:text-sm font-black leading-tight">{name}</span>
|
<span className="flex-1 min-w-0">
|
||||||
<span className="text-[11px] font-medium leading-tight" style={{ opacity: 0.78 }}>
|
<span className="block text-lg short:text-base font-black leading-tight">{name}</span>
|
||||||
|
<span className="block text-[11px] font-medium leading-tight" style={{ opacity: 0.78 }}>
|
||||||
{desc}
|
{desc}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="shrink-0 rounded-full bg-rose-500 px-2 py-0.5 text-[10px] font-bold text-white shadow">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { cn } from "@/lib/cn";
|
|||||||
type Item = { key: Screen; icon: typeof Home; label: string; authed?: boolean; badge?: number };
|
type Item = { key: Screen; icon: typeof Home; label: string; authed?: boolean; badge?: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UNO-style primary navigation. A vertical rail pinned to the side in landscape
|
* UNO-style primary navigation: a single rounded "pill" tab bar floating at the
|
||||||
* (the app's main orientation) and a bottom tab bar in portrait. Active section
|
* bottom on every screen (portrait-first). Active section is highlighted gold.
|
||||||
* is highlighted gold and pulled forward.
|
* `bottom` only drops the redundant "home" item when used on the home screen.
|
||||||
*/
|
*/
|
||||||
export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
||||||
const screen = useUIStore((s) => s.screen);
|
const screen = useUIStore((s) => s.screen);
|
||||||
@@ -48,16 +48,8 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-20 shrink-0 glass flex justify-around gap-1 p-1.5",
|
// A single rounded "pill" navbar everywhere (matches the home screen).
|
||||||
bottom
|
"z-20 shrink-0 glass flex justify-around gap-1 p-1.5 rounded-2xl border border-gold/15"
|
||||||
? "rounded-2xl border border-gold/15"
|
|
||||||
: cn(
|
|
||||||
// portrait: bottom bar; landscape: side rail
|
|
||||||
"border-t border-gold/10 pb-[max(0.375rem,env(safe-area-inset-bottom))]",
|
|
||||||
"landscape:order-first landscape:h-full landscape:w-[78px] landscape:flex-col landscape:justify-center",
|
|
||||||
"landscape:gap-1.5 landscape:py-3 landscape:pb-3 landscape:border-t-0",
|
|
||||||
"ltr:landscape:border-r rtl:landscape:border-l"
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{items.map((it) => {
|
{items.map((it) => {
|
||||||
@@ -67,8 +59,7 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
|||||||
key={it.key}
|
key={it.key}
|
||||||
onClick={() => go(it.authed && !isAuthed ? "auth" : it.key)}
|
onClick={() => go(it.authed && !isAuthed ? "auth" : it.key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex min-w-[54px] flex-col items-center justify-center gap-1 rounded-2xl px-1.5 py-2 transition",
|
"relative flex flex-1 min-w-0 flex-col items-center justify-center gap-1 rounded-2xl px-1 py-2 transition",
|
||||||
!bottom && "landscape:w-full landscape:py-2.5",
|
|
||||||
active ? "btn-gold shadow-lg" : "text-cream/55 hover:bg-navy-800/60 hover:text-cream"
|
active ? "btn-gold shadow-lg" : "text-cream/55 hover:bg-navy-800/60 hover:text-cream"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -77,8 +68,8 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
|||||||
{it.badge > 9 ? "9+" : it.badge}
|
{it.badge > 9 ? "9+" : it.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<it.icon className={cn("size-5", active && "text-[#2a1f04]")} />
|
<it.icon className={cn("size-5 shrink-0", active && "text-[#2a1f04]")} />
|
||||||
<span className={cn("text-[10px] font-bold leading-none", active && "text-[#2a1f04]")}>
|
<span className={cn("max-w-full truncate text-[10px] font-bold leading-none", active && "text-[#2a1f04]")}>
|
||||||
{it.label}
|
{it.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -52,14 +52,21 @@ export function ScreenShell({
|
|||||||
hideNav?: boolean;
|
hideNav?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="persian-pattern relative h-dvh w-full overflow-hidden overscroll-contain safe-top safe-x flex flex-col landscape:flex-row">
|
<main className="persian-pattern relative h-dvh w-full overflow-hidden overscroll-contain safe-top safe-x flex flex-col">
|
||||||
{/* content panel — scrolls internally, uses the width in landscape */}
|
{/* content panel — scrolls internally so the page never scrolls as a whole */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden overscroll-contain">
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden overscroll-contain">
|
||||||
<div className="mx-auto w-full max-w-2xl landscape:max-w-5xl px-4 pt-3 pb-8 sm:px-6">
|
<div className="mx-auto w-full max-w-2xl px-4 pt-3 pb-8 sm:px-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!hideNav && <NavRail />}
|
{/* floating rounded navbar (matches the home screen) */}
|
||||||
|
{!hideNav && (
|
||||||
|
<div className="shrink-0 px-3 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<NavRail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-22
@@ -32,10 +32,8 @@ export const TIMING = {
|
|||||||
/** Base turn time (starter league / vs-AI). Higher leagues use less — see
|
/** Base turn time (starter league / vs-AI). Higher leagues use less — see
|
||||||
* `turnMsForStake`. Kept for reference; scheduling derives the real value. */
|
* `turnMsForStake`. Kept for reference; scheduling derives the real value. */
|
||||||
export const TURN_MS = 15000;
|
export const TURN_MS = 15000;
|
||||||
/** Grace period to wait for a disconnected player to return. */
|
/** Grace period to wait for a disconnected player to return (live games). */
|
||||||
export const RECONNECT_MS = 15000;
|
export const RECONNECT_MS = 15000;
|
||||||
/** Per-turn chance an online opponent briefly drops (mock). */
|
|
||||||
const DISCONNECT_CHANCE = 0.07;
|
|
||||||
|
|
||||||
export type GameMode = "ai" | "online";
|
export type GameMode = "ai" | "online";
|
||||||
|
|
||||||
@@ -267,28 +265,11 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
scheduleAuto();
|
scheduleAuto();
|
||||||
}, turnMs);
|
}, turnMs);
|
||||||
} else {
|
} else {
|
||||||
const st = get();
|
// Opponents (bots / filled seats) simply auto-play their turn — no
|
||||||
if (
|
// simulated disconnects, no pause, no banner.
|
||||||
st.mode === "online" &&
|
|
||||||
st.disconnectedSeat == null &&
|
|
||||||
Math.random() < DISCONNECT_CHANCE
|
|
||||||
) {
|
|
||||||
// simulate this opponent dropping; wait for them, then they return
|
|
||||||
set({
|
|
||||||
turnDeadline: null,
|
|
||||||
disconnectedSeat: seat,
|
|
||||||
reconnectDeadline: Date.now() + RECONNECT_MS,
|
|
||||||
});
|
|
||||||
const back = Math.floor(RECONNECT_MS * (0.4 + Math.random() * 0.45));
|
|
||||||
pending = setTimeout(() => {
|
|
||||||
set({ disconnectedSeat: null, reconnectDeadline: null });
|
|
||||||
playSeatAI(seat);
|
|
||||||
}, back);
|
|
||||||
} else {
|
|
||||||
set({ turnDeadline: null });
|
set({ turnDeadline: null });
|
||||||
pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay));
|
pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user