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";
|
||||
|
||||
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 { useGameStore } from "@/lib/game-store";
|
||||
import { useSoundStore } from "@/lib/sound-store";
|
||||
@@ -210,7 +210,6 @@ export function GameTable({
|
||||
<TurnTimer />
|
||||
<TurnIndicator />
|
||||
</div>
|
||||
<DisconnectBanner />
|
||||
<Reactions />
|
||||
<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 ------------------------------ */
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* mode cards */}
|
||||
{/* mode cards — stacked vertically for portrait */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
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
|
||||
tone="gold"
|
||||
@@ -211,23 +211,25 @@ function ModeCard({
|
||||
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)`,
|
||||
}}
|
||||
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
|
||||
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)" }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-base short:text-sm font-black leading-tight">{name}</span>
|
||||
<span className="text-[11px] font-medium leading-tight" style={{ opacity: 0.78 }}>
|
||||
{desc}
|
||||
<span className="flex-1 min-w-0">
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import { cn } from "@/lib/cn";
|
||||
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
|
||||
* (the app's main orientation) and a bottom tab bar in portrait. Active section
|
||||
* is highlighted gold and pulled forward.
|
||||
* UNO-style primary navigation: a single rounded "pill" tab bar floating at the
|
||||
* bottom on every screen (portrait-first). Active section is highlighted gold.
|
||||
* `bottom` only drops the redundant "home" item when used on the home screen.
|
||||
*/
|
||||
export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
||||
const screen = useUIStore((s) => s.screen);
|
||||
@@ -48,16 +48,8 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"z-20 shrink-0 glass flex justify-around gap-1 p-1.5",
|
||||
bottom
|
||||
? "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"
|
||||
)
|
||||
// A single rounded "pill" navbar everywhere (matches the home screen).
|
||||
"z-20 shrink-0 glass flex justify-around gap-1 p-1.5 rounded-2xl border border-gold/15"
|
||||
)}
|
||||
>
|
||||
{items.map((it) => {
|
||||
@@ -67,8 +59,7 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
||||
key={it.key}
|
||||
onClick={() => go(it.authed && !isAuthed ? "auth" : it.key)}
|
||||
className={cn(
|
||||
"relative flex min-w-[54px] flex-col items-center justify-center gap-1 rounded-2xl px-1.5 py-2 transition",
|
||||
!bottom && "landscape:w-full landscape:py-2.5",
|
||||
"relative flex flex-1 min-w-0 flex-col items-center justify-center gap-1 rounded-2xl px-1 py-2 transition",
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
<it.icon className={cn("size-5", active && "text-[#2a1f04]")} />
|
||||
<span className={cn("text-[10px] font-bold leading-none", active && "text-[#2a1f04]")}>
|
||||
<it.icon className={cn("size-5 shrink-0", active && "text-[#2a1f04]")} />
|
||||
<span className={cn("max-w-full truncate text-[10px] font-bold leading-none", active && "text-[#2a1f04]")}>
|
||||
{it.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -52,14 +52,21 @@ export function ScreenShell({
|
||||
hideNav?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<main className="persian-pattern relative h-dvh w-full overflow-hidden overscroll-contain safe-top safe-x flex flex-col landscape:flex-row">
|
||||
{/* content panel — scrolls internally, uses the width in landscape */}
|
||||
<main className="persian-pattern relative h-dvh w-full overflow-hidden overscroll-contain safe-top safe-x flex flex-col">
|
||||
{/* 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="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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-24
@@ -32,10 +32,8 @@ export const TIMING = {
|
||||
/** Base turn time (starter league / vs-AI). Higher leagues use less — see
|
||||
* `turnMsForStake`. Kept for reference; scheduling derives the real value. */
|
||||
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;
|
||||
/** Per-turn chance an online opponent briefly drops (mock). */
|
||||
const DISCONNECT_CHANCE = 0.07;
|
||||
|
||||
export type GameMode = "ai" | "online";
|
||||
|
||||
@@ -267,27 +265,10 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
scheduleAuto();
|
||||
}, turnMs);
|
||||
} else {
|
||||
const st = get();
|
||||
if (
|
||||
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 });
|
||||
pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay));
|
||||
}
|
||||
// Opponents (bots / filled seats) simply auto-play their turn — no
|
||||
// simulated disconnects, no pause, no banner.
|
||||
set({ turnDeadline: null });
|
||||
pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user