feat: add refractor tier-specific card art rendering

Implement tier-aware visual styling for card PNG rendering (T0-T4).
Each refractor tier gets distinct borders, header backgrounds, column
header gradients, diamond tier indicators, and decorative effects.

- New tier_style.html template: per-tier CSS overrides (borders, headers,
  gradients, inset glow, diamond positioning, corner accents)
- Diamond indicator: 2x2 CSS grid rotated 45deg at header/result boundary,
  progressive fill (1B→2B→3B→Home) with tier-specific colors
- T4 Superfractor: bold gold borders, dual gold-teal glow, corner accents,
  purple diamond with glow pulse animation
- resolve_refractor_tier() helper: pure-math tier lookup from variant hash
- T3/T4 animations defined but paused for static PNG capture (APNG follow-up)

Relates-to: initiative #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-04-04 00:14:33 -05:00
parent ffe07ec54c
commit b32e19a4ac
3 changed files with 272 additions and 0 deletions

View File

@ -32,6 +32,7 @@ from ..db_engine import (
)
from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token
from ..services.refractor_boost import compute_variant_hash
# ---------------------------------------------------------------------------
# Persistent browser instance (WP-02)
@ -132,6 +133,19 @@ def normalize_franchise(franchise: str) -> str:
return FRANCHISE_NORMALIZE.get(titled, titled)
def resolve_refractor_tier(player_id: int, variant: int) -> int:
"""Determine the refractor tier (0-4) from a player's variant hash.
Pure math no DB query needed. Returns 0 for base cards or unknown variants.
"""
if variant == 0:
return 0
for tier in range(1, 5):
if compute_variant_hash(player_id, tier) == variant:
return tier
return 0
router = APIRouter(prefix="/api/v2/players", tags=["players"])
@ -786,6 +800,7 @@ async def get_batter_card(
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = resolve_refractor_tier(player_id, variant)
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
@ -823,6 +838,7 @@ async def get_batter_card(
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = resolve_refractor_tier(player_id, variant)
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)

View File

@ -2,9 +2,32 @@
<html lang="en">
<head>
{% include 'style.html' %}
{% include 'tier_style.html' %}
</head>
<body>
<div id="fullCard" style="width: 1200px; height: 600px;">
{%- set diamond_colors = {
1: {'color': '#1a6b1a', 'highlight': '#40b040'},
2: {'color': '#2070b0', 'highlight': '#50a0e8'},
3: {'color': '#a82020', 'highlight': '#e85050'},
4: {'color': '#6b2d8e', 'highlight': '#a060d0'},
} -%}
{% if refractor_tier is defined and refractor_tier > 0 %}
{%- set dc = diamond_colors[refractor_tier] -%}
{%- set filled_bg = 'linear-gradient(135deg, ' ~ dc.highlight ~ ' 0%, ' ~ dc.color ~ ' 50%, ' ~ dc.color ~ ' 100%)' -%}
<div class="tier-diamond{% if refractor_tier == 4 %} diamond-glow{% endif %}">
<div class="diamond-quad{% if refractor_tier >= 2 %} filled{% endif %}" {% if refractor_tier >= 2 %}style="background: {{ filled_bg }}; box-shadow: inset 0 1px 2px rgba(255,255,255,0.45), inset 0 -1px 2px rgba(0,0,0,0.35), inset 1px 0 2px rgba(255,255,255,0.15);"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 1 %} filled{% endif %}" {% if refractor_tier >= 1 %}style="background: {{ filled_bg }}; box-shadow: inset 0 1px 2px rgba(255,255,255,0.45), inset 0 -1px 2px rgba(0,0,0,0.35), inset 1px 0 2px rgba(255,255,255,0.15);"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 3 %} filled{% endif %}" {% if refractor_tier >= 3 %}style="background: {{ filled_bg }}; box-shadow: inset 0 1px 2px rgba(255,255,255,0.45), inset 0 -1px 2px rgba(0,0,0,0.35), inset 1px 0 2px rgba(255,255,255,0.15);"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 4 %} filled{% endif %}" {% if refractor_tier >= 4 %}style="background: {{ filled_bg }}; box-shadow: inset 0 1px 2px rgba(255,255,255,0.45), inset 0 -1px 2px rgba(0,0,0,0.35), inset 1px 0 2px rgba(255,255,255,0.15);"{% endif %}></div>
</div>
{% if refractor_tier == 4 %}
<div class="corner-accent tl" style="width: 35px; height: 35px; border-top: 3px solid #c9a94e; border-left: 3px solid #c9a94e;"></div>
<div class="corner-accent tr" style="width: 35px; height: 35px; border-top: 3px solid #c9a94e; border-right: 3px solid #c9a94e;"></div>
<div class="corner-accent bl" style="width: 35px; height: 35px; border-bottom: 3px solid #c9a94e; border-left: 3px solid #c9a94e;"></div>
<div class="corner-accent br" style="width: 35px; height: 35px; border-bottom: 3px solid #c9a94e; border-right: 3px solid #c9a94e;"></div>
{% endif %}
{% endif %}
<div id="header" class="row-wrapper header-text border-bot" style="height: 65px">
<!-- <div id="headerLeft" style="flex-grow: 3; height: auto">-->
<div id="headerLeft" style="width: 477px; height: auto">

View File

@ -0,0 +1,233 @@
{% if refractor_tier is defined and refractor_tier > 0 %}
<style>
/* Shared rules for all refractor tiers */
#fullCard {
position: relative;
overflow: hidden;
}
.tier-diamond {
position: absolute;
left: 600px;
top: 78.5px;
transform: translate(-50%, -50%) rotate(45deg);
display: grid;
grid-template: 1fr 1fr / 1fr 1fr;
gap: 2px;
z-index: 20;
pointer-events: none;
background: rgba(0,0,0,0.75);
border-radius: 2px;
box-shadow: 0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5);
}
.diamond-quad {
width: 19px;
height: 19px;
background: rgba(0,0,0,0.3);
}
.corner-accent {
position: absolute;
z-index: 6;
pointer-events: none;
border-style: solid;
border-color: transparent;
}
.corner-accent.tl { top: 0; left: 0; }
.corner-accent.tr { top: 0; right: 0; }
.corner-accent.bl { bottom: 0; left: 0; }
.corner-accent.br { bottom: 0; right: 0; }
{% if refractor_tier == 1 %}
/* T1 — Base Chrome */
#header {
background: linear-gradient(135deg, rgba(185,195,210,0.25) 0%, rgba(210,218,228,0.35) 50%, rgba(185,195,210,0.25) 100%), #ffffff;
}
.border-bot {
border-bottom-color: #8e9baf;
border-bottom-width: 4px;
}
#resultHeader.border-bot {
border-bottom-width: 3px;
}
.border-right-thick {
border-right-color: #8e9baf;
}
.border-right-thin {
border-right-color: #8e9baf;
}
.vline {
border-left-color: #8e9baf;
}
{% elif refractor_tier == 2 %}
/* T2 — Refractor */
#header {
background: linear-gradient(135deg, rgba(100,155,230,0.28) 0%, rgba(155,90,220,0.18) 25%, rgba(90,200,210,0.24) 50%, rgba(185,80,170,0.16) 75%, rgba(100,155,230,0.28) 100%), #ffffff;
}
#fullCard {
box-shadow: inset 0 0 14px 3px rgba(90,143,207,0.22);
}
.border-bot {
border-bottom-color: #7a9cc4;
border-bottom-width: 4px;
}
#resultHeader .border-right-thick {
border-right-width: 6px;
}
.border-right-thick {
border-right-color: #7a9cc4;
}
.border-right-thin {
border-right-color: #7a9cc4;
border-right-width: 3px;
}
.vline {
border-left-color: #7a9cc4;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(60,110,200,1), rgba(100,55,185,0.55), rgba(60,110,200,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(190,35,80,1), rgba(165,25,100,0.55), rgba(190,35,80,1));
}
{% elif refractor_tier == 3 %}
/* T3 — Gold Refractor */
#header {
background: linear-gradient(135deg, rgba(195,155,35,0.26) 0%, rgba(235,200,70,0.2) 50%, rgba(195,155,35,0.26) 100%), #ffffff;
}
#fullCard {
box-shadow: inset 0 0 16px 4px rgba(200,165,48,0.22);
}
.border-bot {
border-bottom-color: #c9a94e;
border-bottom-width: 4px;
}
.border-right-thick {
border-right-color: #c9a94e;
}
.border-right-thin {
border-right-color: #c9a94e;
border-right-width: 3px;
}
.vline {
border-left-color: #c9a94e;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
/* T3 shimmer animation — paused for static PNG capture */
#header { overflow: hidden; position: relative; }
@keyframes t3-shimmer {
0% { transform: translateX(-130%); }
100% { transform: translateX(230%); }
}
#header::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(
105deg,
transparent 38%,
rgba(255,240,140,0.18) 44%,
rgba(255,220,80,0.38) 50%,
rgba(255,200,60,0.30) 53%,
rgba(255,240,140,0.14) 58%,
transparent 64%
);
pointer-events: none;
z-index: 5;
animation: t3-shimmer 2.5s ease-in-out infinite;
animation-play-state: paused;
}
{% elif refractor_tier == 4 %}
/* T4 — Superfractor */
#header {
background: #ffffff;
overflow: hidden;
position: relative;
}
#fullCard {
box-shadow: inset 0 0 22px 6px rgba(45,212,191,0.28), inset 0 0 39px 9px rgba(200,165,48,0.15);
}
.border-bot {
border-bottom-color: #c9a94e;
border-bottom-width: 6px;
}
#resultHeader.border-bot {
border-bottom-width: 5px;
}
.border-right-thick {
border-right-color: #c9a94e;
border-right-width: 7px;
}
.border-right-thin {
border-right-color: #c9a94e;
border-right-width: 4px;
}
.vline {
border-left-color: #c9a94e;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
/* T4 prismatic header sweep — paused for static PNG capture */
@keyframes t4-prismatic-sweep {
0% { transform: translateX(0%); }
100% { transform: translateX(-50%); }
}
#header::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 200%; height: 100%;
background: linear-gradient(135deg,
transparent 2%, rgba(255,100,100,0.28) 8%, rgba(255,200,50,0.32) 14%,
rgba(100,255,150,0.30) 20%, rgba(50,190,255,0.32) 26%, rgba(140,80,255,0.28) 32%,
rgba(255,100,180,0.24) 38%, transparent 44%,
transparent 52%, rgba(255,100,100,0.28) 58%, rgba(255,200,50,0.32) 64%,
rgba(100,255,150,0.30) 70%, rgba(50,190,255,0.32) 76%, rgba(140,80,255,0.28) 82%,
rgba(255,100,180,0.24) 88%, transparent 94%
);
z-index: 1;
pointer-events: none;
animation: t4-prismatic-sweep 6s linear infinite;
animation-play-state: paused;
}
#header > * { position: relative; z-index: 2; }
/* T4 diamond glow pulse — paused for static PNG */
@keyframes diamond-glow-pulse {
0%, 100% { box-shadow: 0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5),
0 0 8px 2px rgba(107,45,142,0.6); }
50% { box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3),
0 0 14px 5px rgba(107,45,142,0.8),
0 0 24px 8px rgba(107,45,142,0.3); }
}
.tier-diamond.diamond-glow {
animation: diamond-glow-pulse 2s ease-in-out infinite;
animation-play-state: paused;
}
{% endif %}
</style>
{% else %}
<style>
/* T0 Standard — no tier overrides */
#fullCard {
position: relative;
overflow: hidden;
}
</style>
{% endif %}