diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css
index d6e4c2f..303943e 100644
--- a/src/app/(frontend)/styles.css
+++ b/src/app/(frontend)/styles.css
@@ -1819,6 +1819,133 @@
overflow-wrap: anywhere;
}
+/* Progressive hand-rank ladder: each tier a step brighter and glowier, from
+ brass hints through silver (straight, flush) and gold (full house, quads)
+ up to the royal flush prismatic treatment. */
+.poker-rank-tier-1 {
+ border-color: rgb(138 109 59 / 80%);
+}
+
+.poker-rank-tier-2 {
+ border-color: rgb(163 130 63 / 85%);
+ box-shadow: inset 0 0 0.4rem rgb(213 179 106 / 16%);
+}
+
+.poker-rank-tier-3 {
+ border-color: rgb(185 138 62 / 90%);
+ box-shadow:
+ inset 0 0 0.5rem rgb(213 179 106 / 24%),
+ 0 0 0.35rem rgb(213 179 106 / 18%);
+}
+
+.poker-rank-tier-4 {
+ border-color: rgb(201 201 212 / 85%);
+ box-shadow:
+ inset 0 0 0.55rem rgb(226 232 240 / 22%),
+ 0 0 0.45rem rgb(226 232 240 / 20%);
+}
+
+.poker-rank-tier-5 {
+ border-color: rgb(214 214 228 / 95%);
+ box-shadow:
+ inset 0 0 0.7rem rgb(226 232 240 / 32%),
+ 0 0 0.7rem rgb(226 232 240 / 30%);
+}
+
+.poker-rank-tier-6 {
+ border-color: #cfa95c;
+ box-shadow:
+ inset 0 0 0.7rem rgb(214 179 106 / 30%),
+ 0 0 0.6rem rgb(214 179 106 / 28%);
+}
+
+.poker-rank-tier-7 {
+ border-color: #e0bd6f;
+ box-shadow:
+ inset 0 0 0.8rem rgb(224 189 111 / 44%),
+ 0 0 0.85rem rgb(224 189 111 / 42%);
+}
+
+.poker-rank-tier-8 {
+ border-color: #79a7f5;
+ box-shadow:
+ inset 0 0 0.9rem rgb(121 167 245 / 46%),
+ 0 0 0.95rem rgb(121 167 245 / 48%);
+}
+
+.poker-rank-tier-9 {
+ position: relative;
+ border-color: #ffd700;
+ background-image: linear-gradient(
+ 115deg,
+ rgb(255 77 77 / 7%),
+ rgb(255 184 77 / 7%) 18%,
+ rgb(255 249 77 / 7%) 36%,
+ rgb(77 255 136 / 7%) 54%,
+ rgb(77 195 255 / 7%) 72%,
+ rgb(184 77 255 / 7%) 88%,
+ rgb(255 77 77 / 7%)
+ );
+ background-size: 300% 100%;
+ box-shadow:
+ inset 0 0 1rem rgb(255 215 0 / 46%),
+ 0 0 1.1rem rgb(255 215 0 / 55%);
+ animation: poker-royal-wash 6s ease-in-out infinite alternate;
+}
+
+/* The rainbow lives only on the border ring (masked pseudo-element) and the
+ near-transparent background wash, never on the element's content: the hue
+ filter animates the pseudo itself, so cards and text keep their true colors. */
+.poker-rank-tier-9::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(
+ 115deg,
+ #ff4d4d,
+ #ffb84d,
+ #fff94d,
+ #4dff88,
+ #4dc3ff,
+ #b84dff,
+ #ff4d4d
+ );
+ background-size: 300% 100%;
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ animation: poker-royal-border-hue 3.2s linear infinite;
+ pointer-events: none;
+}
+
+@keyframes poker-royal-wash {
+ 0% {
+ background-position: 0% 0;
+ }
+ 100% {
+ background-position: 100% 0;
+ }
+}
+
+@keyframes poker-royal-border-hue {
+ 0% {
+ filter: hue-rotate(0deg);
+ background-position: 0% 0;
+ }
+ 100% {
+ filter: hue-rotate(360deg);
+ background-position: 300% 0;
+ }
+}
+
.poker-console-economy {
display: flex;
flex-direction: column;
@@ -2415,6 +2542,11 @@
animation: none;
}
+ .poker-rank-tier-9,
+ .poker-rank-tier-9::before {
+ animation: none;
+ }
+
.poker-card-inner,
.poker-card-deal {
animation: none;
diff --git a/src/components/frontend/minigames/poker/PokerActionConsole.tsx b/src/components/frontend/minigames/poker/PokerActionConsole.tsx
index 3208f56..27f7597 100644
--- a/src/components/frontend/minigames/poker/PokerActionConsole.tsx
+++ b/src/components/frontend/minigames/poker/PokerActionConsole.tsx
@@ -14,6 +14,7 @@ type SeatedSnapshot = PokerSnapshot & {
interface PokerActionConsoleProps {
readonly snapshot: SeatedSnapshot;
readonly handLabel: string | null;
+ readonly handTier: number | null;
readonly hostSeatId: number | null;
readonly starting: boolean;
readonly leaving: boolean;
@@ -80,6 +81,7 @@ function ViewerRoleBadges({ view }: { readonly view: SeatedSnapshot["you"] }) {
export function PokerActionConsole({
snapshot,
handLabel,
+ handTier,
hostSeatId,
starting,
leaving,
@@ -125,7 +127,15 @@ export function PokerActionConsole({
- {handLabel && You have: {handLabel}}
+ {handLabel && (
+ 0 ? ` poker-rank-tier-${handTier}` : ""
+ }`}
+ >
+ You have: {handLabel}
+
+ )}
diff --git a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
index 6732e74..4149b02 100644
--- a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
+++ b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
@@ -30,6 +30,7 @@ interface PokerFeltCanvasProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly handLabel: string | null;
+ readonly handTier: number | null;
readonly bestCardKeys: ReadonlySet;
readonly actionProps: FeltCanvasActionProps;
readonly onActionFeedback: () => void;
@@ -82,6 +83,7 @@ export function PokerFeltCanvas({
snapshot,
onSnapshot,
handLabel,
+ handTier,
bestCardKeys,
actionProps,
onActionFeedback,
@@ -264,6 +266,7 @@ export function PokerFeltCanvas({
new Set(handHint?.bestCardKeys ?? []), [handHint]);
+ const handTier = handHint?.tier ?? null;
useEffect(() => {
setMounted(true);
@@ -87,6 +88,7 @@ export function PokerGameTable({
snapshot={snapshot}
onSnapshot={onSnapshot}
handLabel={handHint?.label ?? null}
+ handTier={handTier}
bestCardKeys={bestCardKeys}
actionProps={{
starting,
diff --git a/src/lib/minigames/poker/handHint.ts b/src/lib/minigames/poker/handHint.ts
index cf55198..77d25d0 100644
--- a/src/lib/minigames/poker/handHint.ts
+++ b/src/lib/minigames/poker/handHint.ts
@@ -1,9 +1,12 @@
import { HAND_CATEGORIES, HAND_CATEGORY_NAMES, evaluateHand } from "./evaluator";
+import { handRankTier } from "./handRankStyle";
import type { Card, Rank } from "./types";
export interface HandStrengthHint {
readonly label: string;
readonly bestCardKeys: string[];
+ /** Presentation tier 0-9 driving the progressive rank styling. */
+ readonly tier: number;
}
const RANK_NAMES: Record = {
@@ -63,7 +66,7 @@ function postflopLabel(category: number, tiebreakers: readonly number[]): string
case HAND_CATEGORIES.QUADS:
return `Four of a kind, ${pluralRankName(first)}`;
case HAND_CATEGORIES.STRAIGHT_FLUSH:
- return `Straight flush, ${highCardLabel(first)}`;
+ return first === 14 ? "Royal flush" : `Straight flush, ${highCardLabel(first)}`;
default:
return capitalize(HAND_CATEGORY_NAMES[category] ?? "unknown hand");
}
@@ -83,6 +86,7 @@ export function describeHandStrength(
? `Pair of ${pluralRankName(highCard.rank)}`
: `High card ${rankName(highCard.rank)}`,
bestCardKeys: pair ? holeCards.map(cardKey) : [cardKey(highCard)],
+ tier: pair ? HAND_CATEGORIES.PAIR : HAND_CATEGORIES.HIGH_CARD,
};
}
@@ -93,5 +97,6 @@ export function describeHandStrength(
return {
label: postflopLabel(evaluation.rank.category, evaluation.rank.tiebreakers),
bestCardKeys: evaluation.best.map(cardKey),
+ tier: handRankTier(evaluation.rank),
};
}
diff --git a/src/lib/minigames/poker/handRankStyle.ts b/src/lib/minigames/poker/handRankStyle.ts
new file mode 100644
index 0000000..603c0f2
--- /dev/null
+++ b/src/lib/minigames/poker/handRankStyle.ts
@@ -0,0 +1,18 @@
+import { HAND_CATEGORIES } from "./evaluator";
+import type { HandRank } from "./types";
+
+/**
+ * Presentation tier for a made hand: 0 (high card, no styling) through
+ * 9 (royal flush, full prismatic treatment). Each step up the ladder gets a
+ * more intense border and glow; see the poker-rank-tier-* rules in styles.css.
+ */
+export const ROYAL_RANK_TIER = 9;
+
+export function handRankTier(hand: HandRank): number {
+ const royal = hand.category === HAND_CATEGORIES.STRAIGHT_FLUSH && hand.tiebreakers[0] === 14;
+ return royal ? ROYAL_RANK_TIER : hand.category;
+}
+
+export function handRankTierClass(tier: number): string {
+ return `poker-rank-tier-${tier}`;
+}
diff --git a/tests/int/poker-hand-hint.int.spec.ts b/tests/int/poker-hand-hint.int.spec.ts
index 50eea4d..eddbccc 100644
--- a/tests/int/poker-hand-hint.int.spec.ts
+++ b/tests/int/poker-hand-hint.int.spec.ts
@@ -9,6 +9,7 @@ describe("poker hand strength hints", () => {
expect(describeHandStrength([c(13, "s"), c(13, "h")], [])).toEqual({
label: "Pair of Kings",
bestCardKeys: ["13-s", "13-h"],
+ tier: 1,
});
});
@@ -16,6 +17,7 @@ describe("poker hand strength hints", () => {
expect(describeHandStrength([c(14, "s"), c(9, "h")], [])).toEqual({
label: "High card Ace",
bestCardKeys: ["14-s"],
+ tier: 0,
});
});
diff --git a/tests/int/poker-rank-style.int.spec.ts b/tests/int/poker-rank-style.int.spec.ts
new file mode 100644
index 0000000..c716214
--- /dev/null
+++ b/tests/int/poker-rank-style.int.spec.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import type { Card, HandRank } from "@/lib/minigames/poker";
+import { handRankTier, ROYAL_RANK_TIER } from "@/lib/minigames/poker/handRankStyle";
+import { describeHandStrength } from "@/lib/minigames/poker/handHint";
+
+function rank(category: number, tiebreakers: readonly number[]): HandRank {
+ return { category, tiebreakers };
+}
+
+describe("handRankTier", () => {
+ it("maps every category to its own tier", () => {
+ expect(handRankTier(rank(0, [14]))).toBe(0);
+ expect(handRankTier(rank(1, [9]))).toBe(1);
+ expect(handRankTier(rank(2, [11, 4]))).toBe(2);
+ expect(handRankTier(rank(3, [7]))).toBe(3);
+ expect(handRankTier(rank(4, [9]))).toBe(4);
+ expect(handRankTier(rank(5, [14]))).toBe(5);
+ expect(handRankTier(rank(6, [13, 8]))).toBe(6);
+ expect(handRankTier(rank(7, [3]))).toBe(7);
+ expect(handRankTier(rank(8, [9]))).toBe(8);
+ });
+
+ it("promotes only the ace-high straight flush to the royal tier", () => {
+ expect(handRankTier(rank(8, [14]))).toBe(ROYAL_RANK_TIER);
+ expect(handRankTier(rank(8, [13]))).toBe(8);
+ expect(handRankTier(rank(4, [14]))).not.toBe(ROYAL_RANK_TIER);
+ });
+});
+
+describe("describeHandStrength tiers", () => {
+ it("keeps high card at tier zero and a pocket pair at tier one", () => {
+ const high = describeHandStrength(
+ [
+ { rank: 14, suit: "s" },
+ { rank: 13, suit: "h" },
+ ],
+ [],
+ );
+ const pair = describeHandStrength(
+ [
+ { rank: 9, suit: "s" },
+ { rank: 9, suit: "h" },
+ ],
+ [],
+ );
+ expect(high?.tier).toBe(0);
+ expect(pair?.tier).toBe(1);
+ });
+
+ it("grades a royal flush at the top tier with the royal label", () => {
+ const hole: Card[] = [
+ { rank: 10, suit: "h" },
+ { rank: 11, suit: "h" },
+ ];
+ const board: Card[] = [
+ { rank: 12, suit: "h" },
+ { rank: 13, suit: "h" },
+ { rank: 14, suit: "h" },
+ { rank: 2, suit: "s" },
+ { rank: 3, suit: "d" },
+ ];
+ const hint = describeHandStrength(hole, board);
+ expect(hint?.tier).toBe(ROYAL_RANK_TIER);
+ expect(hint?.label).toBe("Royal flush");
+ });
+});