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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
f7bb8f1e9f
commit
9b2b0db553
4 changed files with 228 additions and 21 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
|
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
|
||||||
|
import { mapCardToSvg, useCardStyle } from "./cardStyle";
|
||||||
|
|
||||||
const RANK_LABELS: Record<Rank, string> = {
|
const RANK_LABELS: Record<Rank, string> = {
|
||||||
2: "2",
|
2: "2",
|
||||||
|
|
@ -47,7 +48,11 @@ export function CardView({
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
dimmed = false,
|
dimmed = false,
|
||||||
}: CardViewProps) {
|
}: CardViewProps) {
|
||||||
|
const { style: cardStyle } = useCardStyle();
|
||||||
|
const lightFace = cardStyle === "light";
|
||||||
|
const classicFace = cardStyle === "classic";
|
||||||
const revealed = card !== null && faceUp;
|
const revealed = card !== null && faceUp;
|
||||||
|
const redSuitClass = lightFace ? "text-rose-600" : "text-rose-400";
|
||||||
const label = card
|
const label = card
|
||||||
? `${RANK_LABELS[card.rank]} of ${
|
? `${RANK_LABELS[card.rank]} of ${
|
||||||
card.suit === "s"
|
card.suit === "s"
|
||||||
|
|
@ -76,28 +81,38 @@ export function CardView({
|
||||||
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
|
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
|
<span className="poker-card-motion">
|
||||||
<span className="poker-card-face poker-card-front" aria-hidden={!revealed}>
|
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
|
||||||
{card && (
|
<span
|
||||||
<>
|
className={cn(
|
||||||
<span
|
"poker-card-face poker-card-front gap-1",
|
||||||
className={cn(
|
lightFace && "poker-card-face--light",
|
||||||
"flex flex-col items-center leading-none",
|
classicFace && "poker-card-front--svg",
|
||||||
RED_SUITS.has(card.suit) && "text-rose-400",
|
)}
|
||||||
)}
|
aria-hidden={!revealed}
|
||||||
>
|
>
|
||||||
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
|
{card && classicFace && (
|
||||||
<span>{SUIT_SYMBOLS[card.suit]}</span>
|
<img src={mapCardToSvg(card)} alt="" draggable={false} className="poker-card-svg" />
|
||||||
</span>
|
)}
|
||||||
<span
|
{card && !classicFace && (
|
||||||
className={cn("poker-card-center", RED_SUITS.has(card.suit) && "text-rose-400")}
|
<>
|
||||||
>
|
<span
|
||||||
{SUIT_SYMBOLS[card.suit]}
|
className={cn(
|
||||||
</span>
|
"flex flex-col items-center leading-none",
|
||||||
</>
|
RED_SUITS.has(card.suit) && redSuitClass,
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
|
||||||
|
<span>{SUIT_SYMBOLS[card.suit]}</span>
|
||||||
|
</span>
|
||||||
|
<span className={cn("poker-card-center", RED_SUITS.has(card.suit) && redSuitClass)}>
|
||||||
|
{SUIT_SYMBOLS[card.suit]}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
|
||||||
</span>
|
</span>
|
||||||
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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<PokerCardStyle, LucideIcon> = {
|
||||||
|
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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label={`Card style: ${label}`}
|
||||||
|
title={`Card style: ${label}`}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={style}
|
||||||
|
onValueChange={(value) => setStyle(normalizeCardStyle(value))}
|
||||||
|
>
|
||||||
|
{POKER_CARD_STYLE_OPTIONS.map((option) => (
|
||||||
|
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/frontend/minigames/poker/cardStyle.tsx
Normal file
89
src/components/frontend/minigames/poker/cardStyle.tsx
Normal file
|
|
@ -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<number, string> = {
|
||||||
|
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<CardStyleContextValue>({
|
||||||
|
style: "light",
|
||||||
|
setStyle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function PokerCardStyleProvider({ children }: { readonly children: ReactNode }) {
|
||||||
|
const [style, setStyleState] = useState<PokerCardStyle>("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 <CardStyleContext.Provider value={value}>{children}</CardStyleContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCardStyle(): CardStyleContextValue {
|
||||||
|
return useContext(CardStyleContext);
|
||||||
|
}
|
||||||
45
tests/int/poker-card-skins.int.spec.ts
Normal file
45
tests/int/poker-card-skins.int.spec.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue