From 9b2b0db5535b93113cf4672dcbf26c5e64ff82f6 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 11 Sep 2026 17:07:05 -0400 Subject: [PATCH] feat(poker): add card skin toggle with light, dark, and classic svg decks Faces render white with dark ink by default, the previous dark theme cards remain one toggle away, and the classic deck draws the svg assets from public/images/assets/playing-cards. The choice persists per browser. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../frontend/minigames/poker/CardView.tsx | 57 +++++++----- .../minigames/poker/PokerCardStyleToggle.tsx | 58 ++++++++++++ .../frontend/minigames/poker/cardStyle.tsx | 89 +++++++++++++++++++ tests/int/poker-card-skins.int.spec.ts | 45 ++++++++++ 4 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx create mode 100644 src/components/frontend/minigames/poker/cardStyle.tsx create mode 100644 tests/int/poker-card-skins.int.spec.ts diff --git a/src/components/frontend/minigames/poker/CardView.tsx b/src/components/frontend/minigames/poker/CardView.tsx index e7df31a..a8726e9 100644 --- a/src/components/frontend/minigames/poker/CardView.tsx +++ b/src/components/frontend/minigames/poker/CardView.tsx @@ -2,6 +2,7 @@ import { cn } from "@/lib/utils"; import type { Card, Rank, Suit } from "@/lib/minigames/poker"; +import { mapCardToSvg, useCardStyle } from "./cardStyle"; const RANK_LABELS: Record = { 2: "2", @@ -47,7 +48,11 @@ export function CardView({ highlighted = false, dimmed = false, }: CardViewProps) { + const { style: cardStyle } = useCardStyle(); + const lightFace = cardStyle === "light"; + const classicFace = cardStyle === "classic"; const revealed = card !== null && faceUp; + const redSuitClass = lightFace ? "text-rose-600" : "text-rose-400"; const label = card ? `${RANK_LABELS[card.rank]} of ${ card.suit === "s" @@ -76,28 +81,38 @@ export function CardView({ style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined} aria-label={label} > - - - {card && ( - <> - - {RANK_LABELS[card.rank]} - {SUIT_SYMBOLS[card.suit]} - - - {SUIT_SYMBOLS[card.suit]} - - - )} + + + + {card && classicFace && ( + + )} + {card && !classicFace && ( + <> + + {RANK_LABELS[card.rank]} + {SUIT_SYMBOLS[card.suit]} + + + {SUIT_SYMBOLS[card.suit]} + + + )} + + - ); diff --git a/src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx b/src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx new file mode 100644 index 0000000..1ad1820 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Images, Moon, Sun } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + POKER_CARD_STYLE_OPTIONS, + normalizeCardStyle, + useCardStyle, + type PokerCardStyle, +} from "./cardStyle"; + +const STYLE_ICONS: Record = { + light: Sun, + dark: Moon, + classic: Images, +}; + +export function PokerCardStyleToggle() { + const { style, setStyle } = useCardStyle(); + const Icon = STYLE_ICONS[style]; + const label = POKER_CARD_STYLE_OPTIONS.find((option) => option.value === style)?.label ?? "Light"; + + return ( + + + + + + setStyle(normalizeCardStyle(value))} + > + {POKER_CARD_STYLE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ); +} diff --git a/src/components/frontend/minigames/poker/cardStyle.tsx b/src/components/frontend/minigames/poker/cardStyle.tsx new file mode 100644 index 0000000..99573cc --- /dev/null +++ b/src/components/frontend/minigames/poker/cardStyle.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import type { Card } from "@/lib/minigames/poker"; + +export type PokerCardStyle = "light" | "dark" | "classic"; + +const CARD_STYLE_STORAGE_KEY = "poker-card-style"; + +const RANK_FILE_PREFIXES: Record = { + 11: "jack", + 12: "queen", + 13: "king", + 14: "ace", +}; + +const SUIT_FILE_SUFFIXES = { + s: "spades", + h: "hearts", + d: "diamonds", + c: "clubs", +} as const; + +export function mapCardToSvg(card: Card): string { + const prefix = RANK_FILE_PREFIXES[card.rank] ?? String(card.rank); + const suffix = SUIT_FILE_SUFFIXES[card.suit]; + return `/images/assets/playing-cards/${prefix}_of_${suffix}.svg`; +} + +export function normalizeCardStyle(value: unknown): PokerCardStyle { + return value === "dark" || value === "classic" ? value : "light"; +} + +export const POKER_CARD_STYLE_OPTIONS: readonly { + readonly value: PokerCardStyle; + readonly label: string; +}[] = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "classic", label: "Classic SVG" }, +]; + +interface CardStyleContextValue { + readonly style: PokerCardStyle; + readonly setStyle: (style: PokerCardStyle) => void; +} + +const CardStyleContext = createContext({ + style: "light", + setStyle: () => {}, +}); + +export function PokerCardStyleProvider({ children }: { readonly children: ReactNode }) { + const [style, setStyleState] = useState("light"); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(CARD_STYLE_STORAGE_KEY); + if (stored !== null) setStyleState(normalizeCardStyle(stored)); + } catch { + // storage unavailable (private mode, disabled): keep the default + } + }, []); + + const setStyle = useCallback((next: PokerCardStyle) => { + setStyleState(next); + try { + window.localStorage.setItem(CARD_STYLE_STORAGE_KEY, next); + } catch { + // non-fatal: the choice just will not persist + } + }, []); + + const value = useMemo(() => ({ style, setStyle }), [style, setStyle]); + + return {children}; +} + +export function useCardStyle(): CardStyleContextValue { + return useContext(CardStyleContext); +} diff --git a/tests/int/poker-card-skins.int.spec.ts b/tests/int/poker-card-skins.int.spec.ts new file mode 100644 index 0000000..0268e1a --- /dev/null +++ b/tests/int/poker-card-skins.int.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { Card } from "@/lib/minigames/poker"; +import { mapCardToSvg, normalizeCardStyle } from "@/components/frontend/minigames/poker/cardStyle"; + +function cardOf(rank: Card["rank"], suit: Card["suit"]): Card { + return { rank, suit }; +} + +describe("mapCardToSvg", () => { + it("maps number cards to their numbered asset path", () => { + expect(mapCardToSvg(cardOf(7, "h"))).toBe("/images/assets/playing-cards/7_of_hearts.svg"); + expect(mapCardToSvg(cardOf(10, "s"))).toBe("/images/assets/playing-cards/10_of_spades.svg"); + expect(mapCardToSvg(cardOf(2, "c"))).toBe("/images/assets/playing-cards/2_of_clubs.svg"); + }); + + it("maps face ranks to word asset paths", () => { + expect(mapCardToSvg(cardOf(11, "d"))).toBe("/images/assets/playing-cards/jack_of_diamonds.svg"); + expect(mapCardToSvg(cardOf(12, "c"))).toBe("/images/assets/playing-cards/queen_of_clubs.svg"); + expect(mapCardToSvg(cardOf(13, "h"))).toBe("/images/assets/playing-cards/king_of_hearts.svg"); + expect(mapCardToSvg(cardOf(14, "s"))).toBe("/images/assets/playing-cards/ace_of_spades.svg"); + }); + + it("covers all four suits for a spot-check rank", () => { + expect(mapCardToSvg(cardOf(9, "s"))).toBe("/images/assets/playing-cards/9_of_spades.svg"); + expect(mapCardToSvg(cardOf(9, "h"))).toBe("/images/assets/playing-cards/9_of_hearts.svg"); + expect(mapCardToSvg(cardOf(9, "d"))).toBe("/images/assets/playing-cards/9_of_diamonds.svg"); + expect(mapCardToSvg(cardOf(9, "c"))).toBe("/images/assets/playing-cards/9_of_clubs.svg"); + }); +}); + +describe("normalizeCardStyle", () => { + it("accepts the three known style values", () => { + expect(normalizeCardStyle("light")).toBe("light"); + expect(normalizeCardStyle("dark")).toBe("dark"); + expect(normalizeCardStyle("classic")).toBe("classic"); + }); + + it("falls back to light for unknown or missing values", () => { + expect(normalizeCardStyle("bogus")).toBe("light"); + expect(normalizeCardStyle(null)).toBe("light"); + expect(normalizeCardStyle(undefined)).toBe("light"); + expect(normalizeCardStyle(42)).toBe("light"); + expect(normalizeCardStyle("")).toBe("light"); + }); +});