1
0
Fork 0

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:
Jason Fraley 2026-09-11 17:07:05 -04:00
parent f7bb8f1e9f
commit 9b2b0db553
4 changed files with 228 additions and 21 deletions

View file

@ -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<Rank, string> = {
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,22 +81,31 @@ export function CardView({
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
aria-label={label}
>
<span className="poker-card-motion">
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
<span className="poker-card-face poker-card-front" aria-hidden={!revealed}>
{card && (
<span
className={cn(
"poker-card-face poker-card-front gap-1",
lightFace && "poker-card-face--light",
classicFace && "poker-card-front--svg",
)}
aria-hidden={!revealed}
>
{card && classicFace && (
<img src={mapCardToSvg(card)} alt="" draggable={false} className="poker-card-svg" />
)}
{card && !classicFace && (
<>
<span
className={cn(
"flex flex-col items-center leading-none",
RED_SUITS.has(card.suit) && "text-rose-400",
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) && "text-rose-400")}
>
<span className={cn("poker-card-center", RED_SUITS.has(card.suit) && redSuitClass)}>
{SUIT_SYMBOLS[card.suit]}
</span>
</>
@ -100,5 +114,6 @@ export function CardView({
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
</span>
</span>
</span>
);
}

View file

@ -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>
);
}

View 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);
}

View 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");
});
});