ui: unified rounded navbar everywhere, vertical home actions, no bot disconnect spam
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 8m9s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 57s

- 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:
soroush.asadi
2026-06-12 01:12:26 +03:30
parent 55c0407d73
commit a7c0900c3b
5 changed files with 39 additions and 86 deletions
+1 -29
View File
@@ -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> = {
+14 -12
View File
@@ -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>
);
}
+8 -17
View File
@@ -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>
+11 -4
View File
@@ -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
View File
@@ -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;
}