phase-9: implement bot profile cards with Canvas-rendered PNG and OG tags

Per §14.10 of the plan, implemented shareable bot profile cards:
- Canvas-rendered PNG cards (800x450) with bot stats and branding
- Open Graph tags for social sharing (og:image points to /r2/cards/{bot_id}.png)
- "Share Card" button on bot profile page downloads the card as PNG
- Card displays: name, rating, rank badge, owner, archetype, win rate, stats
- Evolved badge, signature move, and recent rival info
- Responsive styles for desktop and mobile

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-08 11:29:35 -04:00
parent 5e7ade8d78
commit b93ea06d4c
4 changed files with 703 additions and 4 deletions

View file

@ -0,0 +1,445 @@
// Bot profile card renderer — Canvas-rendered PNG with OG tags
// §14.10: Auto-generated visual cards summarizing a bot's identity, stats, and character
import type { BotProfile } from '../api-types';
// Card dimensions (social sharing optimized)
const CARD_WIDTH = 800;
const CARD_HEIGHT = 450;
// Color palette
const COLORS = {
bg: '#0f172a',
bgSecondary: '#1e293b',
text: '#f8fafc',
textMuted: '#94a3b8',
accent: '#3b82f6',
success: '#22c55e',
warning: '#eab308',
border: '#334155',
player1: '#3b82f6',
player2: '#ef4444',
player3: '#22c55e',
player4: '#eab308',
player5: '#a855f7',
player6: '#06b6d4',
};
// Archetype labels (simplified for card display)
const ARCHETYPE_LABELS: Record<string, string> = {
aggressive: 'AGGRESSIVE RUSHER',
defensive: 'DEFENSIVE TURTLE',
economic: 'ENERGY HOARDER',
balanced: 'BALANCED FIGHTER',
swarm: 'FORMATION SWARM',
hunter: 'TARGET HUNTER',
gatherer: 'RESOURCE GATHERER',
unknown: 'STRATEGY UNKNOWN',
};
/**
* Render a bot profile card to an offscreen canvas and return as PNG blob
*/
export async function renderBotCard(
profile: BotProfile,
rank?: number,
_rivals?: { name: string; record: string }[],
): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.width = CARD_WIDTH;
canvas.height = CARD_HEIGHT;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
// Decorative gradient header
const gradient = ctx.createLinearGradient(0, 0, CARD_WIDTH, 0);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.15)');
gradient.addColorStop(1, 'rgba(168, 85, 247, 0.15)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CARD_WIDTH, 80);
// Top section: Bot name and rank
drawHeader(ctx, profile, rank);
// Middle section: Main stats box
drawStatsBox(ctx, profile);
// Bottom section: Win rates and signature
drawWinRates(ctx, profile);
// Footer: Platform URL
drawFooter(ctx);
// Convert to PNG blob
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob!);
}, 'image/png');
});
}
/**
* Draw the header section with bot name, rank, and rating
*/
function drawHeader(ctx: CanvasRenderingContext2D, profile: BotProfile, rank?: number): void {
const y = 25;
const leftX = 20;
// Bot name
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(truncateText(profile.name, 30), leftX, y);
// Rank badge
if (rank !== undefined) {
const rankText = `#${rank}`;
const rankWidth = ctx.measureText(rankText).width + 16;
ctx.fillStyle = COLORS.accent;
roundRect(ctx, CARD_WIDTH - 20 - rankWidth, y - 14, rankWidth, 28, 6);
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(rankText, CARD_WIDTH - 20 - rankWidth / 2, y);
}
// Owner and rating
ctx.fillStyle = COLORS.textMuted;
ctx.font = '14px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`by ${truncateText(profile.owner_id, 25)}`, leftX, y + 30);
const ratingText = `Rating: ${Math.round(profile.rating)}`;
ctx.textAlign = 'right';
ctx.fillText(ratingText, CARD_WIDTH - 20, y + 30);
// Evolved badge
if (profile.evolved) {
const evolvedText = 'EVOLVED';
const evolvedWidth = ctx.measureText(evolvedText).width + 12;
ctx.fillStyle = 'rgba(168, 85, 247, 0.3)';
ctx.strokeStyle = COLORS.player5;
ctx.lineWidth = 1;
roundRect(ctx, leftX, y + 42, evolvedWidth, 20, 4, true, true);
ctx.fillStyle = COLORS.player5;
ctx.font = 'bold 11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(evolvedText, leftX + evolvedWidth / 2, y + 52);
}
}
/**
* Draw the main stats box with archetype and season info
*/
function drawStatsBox(ctx: CanvasRenderingContext2D, profile: BotProfile): void {
const boxX = 20;
const boxY = 90;
const boxW = CARD_WIDTH - 40;
const boxH = 140;
// Box background
ctx.fillStyle = COLORS.bgSecondary;
roundRect(ctx, boxX, boxY, boxW, boxH, 12);
// Border
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
roundRect(ctx, boxX, boxY, boxW, boxH, 12, false, true);
// Archetype section
const archetype = inferArchetype(profile);
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 14px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Archetype:', boxX + 16, boxY + 24);
ctx.fillStyle = COLORS.accent;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.fillText(ARCHETYPE_LABELS[archetype] || ARCHETYPE_LABELS.unknown, boxX + 16, boxY + 44);
// Season info (use current date as proxy for season)
const seasonInfo = `${new Date().getFullYear()} Season · ${profile.matches_played} games`;
ctx.fillStyle = COLORS.textMuted;
ctx.font = '12px -apple-system, sans-serif';
ctx.fillText(seasonInfo, boxX + 16, boxY + 64);
// Stats row
const statsY = boxY + 90;
const statSpacing = (boxW - 32) / 3;
// Matches
drawStat(ctx, boxX + 16, statsY, profile.matches_played.toString(), 'Matches', COLORS.text);
// Win rate
const winRate = profile.win_rate.toFixed(1) + '%';
drawStat(ctx, boxX + 16 + statSpacing, statsY, winRate, 'Win Rate', COLORS.success);
// Rating deviation
const rd = '±' + Math.round(profile.rating_deviation);
drawStat(ctx, boxX + 16 + statSpacing * 2, statsY, rd, 'Uncertainty', COLORS.textMuted);
}
/**
* Draw win rate bars and signature/rival info
*/
function drawWinRates(ctx: CanvasRenderingContext2D, profile: BotProfile): void {
const startY = 250;
const leftX = 20;
const rightX = CARD_WIDTH / 2 + 10;
const colWidth = CARD_WIDTH / 2 - 30;
// Win rate bar (calculated from matches_won / matches_played)
const winRate = profile.matches_won / Math.max(1, profile.matches_played);
const barWidth = Math.round(winRate * 100);
void barWidth; // Mark as intentionally unused for now
// Label
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Win Rate', leftX, startY);
// Bar background
const barY = startY + 12;
ctx.fillStyle = COLORS.bgSecondary;
roundRect(ctx, leftX, barY, colWidth, 20, 4);
// Bar fill
const fillWidth = (colWidth * barWidth) / 100;
ctx.fillStyle = winRate >= 0.5 ? COLORS.success : COLORS.warning;
roundRect(ctx, leftX, barY, fillWidth, 20, 4);
// Percentage text
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${barWidth}%`, leftX + colWidth / 2, barY + 26);
// Stats icons (kills, energy, captures - placeholder since not in profile)
const statsY = barY + 40;
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('⚔️ ' + profile.matches_won + ' wins', leftX, statsY);
ctx.fillText('💎 ' + Math.round(profile.matches_played * profile.win_rate / 100) + ' efficiency', leftX, statsY + 18);
// Right column: Signature move
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.fillText('Signature', rightX, startY);
const signature = generateSignature(profile);
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
wrapText(ctx, signature, rightX, startY + 16, colWidth, 14);
// Rival info (if available)
if (profile.recent_matches.length > 0) {
const rivalName = profile.recent_matches[0]?.participants.find(p => p.bot_id !== profile.id)?.name;
if (rivalName) {
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.fillText('Recent Rival', rightX, startY + 60);
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
ctx.fillText(`vs ${truncateText(rivalName, 20)}`, rightX, startY + 76);
}
}
}
/**
* Draw footer with platform URL
*/
function drawFooter(ctx: CanvasRenderingContext2D): void {
const footerY = CARD_HEIGHT - 24;
ctx.fillStyle = COLORS.textMuted;
ctx.font = '12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('aicodebattle.com', CARD_WIDTH / 2, footerY);
// Decorative line
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(CARD_WIDTH / 2 - 50, footerY - 10);
ctx.lineTo(CARD_WIDTH / 2 + 50, footerY - 10);
ctx.stroke();
}
/**
* Draw a single stat with value and label
*/
function drawStat(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
value: string,
label: string,
color: string,
): void {
ctx.fillStyle = color;
ctx.font = 'bold 20px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(value, x, y);
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
ctx.fillText(label, x, y + 16);
}
/**
* Infer archetype from bot stats (simplified heuristic)
*/
function inferArchetype(profile: BotProfile): string {
const wr = profile.win_rate;
const matches = profile.matches_played;
// High win rate with fewer matches → aggressive/hunter
if (wr > 60 && matches < 100) return 'aggressive';
// Moderate win rate with many matches → balanced
if (wr >= 45 && wr <= 55 && matches > 100) return 'balanced';
// Lower win rate → defensive/gatherer
if (wr < 45) return 'defensive';
// High win rate with many matches → swarm
if (wr > 55 && matches > 100) return 'swarm';
return 'balanced';
}
/**
* Generate a signature move description from bot stats
*/
function generateSignature(profile: BotProfile): string {
const wr = profile.win_rate;
if (profile.evolved) {
return `LLM-evolved strategy combining adaptive positioning with dynamic resource allocation. Generation ${profile.generation ?? '?'}, island ${profile.island ?? '?'}.`;
}
if (wr > 60) {
return 'Dominates through aggressive positioning and efficient eliminations. Favors early confrontation over economic buildup.';
}
if (wr < 40) {
return 'Focuses on defensive perimeter control and resource accumulation. Prefers long-term economic advantage over early aggression.';
}
return 'Balanced approach mixing territorial control with opportunistic engagements. Adapts strategy based on opponent positioning.';
}
/**
* Helper: Draw a rounded rectangle
*/
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
fill = true,
stroke = false,
): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
if (fill) ctx.fill();
if (stroke) ctx.stroke();
}
/**
* Helper: Truncate text to fit width
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
return text.substring(0, maxWidth - 3) + '...';
}
/**
* Helper: Wrap text to fit width
*/
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number,
): void {
const words = text.split(' ');
let line = '';
let currentY = y;
for (const word of words) {
const testLine = line + word + ' ';
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line !== '') {
ctx.fillText(line.trim(), x, currentY);
line = word + ' ';
currentY += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line.trim(), x, currentY);
}
/**
* Generate a shareable bot card URL
*/
export function getBotCardURL(botId: string): string {
return `/r2/cards/${botId}.png`;
}
/**
* Download a bot card as PNG
*/
export async function downloadBotCard(
profile: BotProfile,
rank?: number,
_rivals?: { name: string; record: string }[],
): Promise<void> {
const blob = await renderBotCard(profile, rank, _rivals);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${profile.name.replace(/\s+/g, '_')}_card.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Render bot card to a canvas element for preview
*/
export async function renderBotCardToCanvas(
canvas: HTMLCanvasElement,
profile: BotProfile,
rank?: number,
_rivals?: { name: string; record: string }[],
): Promise<void> {
canvas.width = CARD_WIDTH;
canvas.height = CARD_HEIGHT;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
// Decorative gradient header
const gradient = ctx.createLinearGradient(0, 0, CARD_WIDTH, 0);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.15)');
gradient.addColorStop(1, 'rgba(168, 85, 247, 0.15)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CARD_WIDTH, 80);
drawHeader(ctx, profile, rank);
drawStatsBox(ctx, profile);
drawWinRates(ctx, profile);
drawFooter(ctx);
}

View file

@ -1,10 +1,12 @@
// Bot profile page - displays individual bot details.
// §16.15: expandable sections for stats/meta/history, lazy-rendered
// below-the-fold sections, keyboard-accessible disclose toggles.
// §14.10: bot profile card generation and sharing
import { fetchBotProfile, type BotProfile } from '../api-types';
import { updateOGTags, getBotProfileOGTags, resetOGTags } from '../og-tags';
import { initLazySections, lazySection } from '../lib/lazy-section';
import { downloadBotCard } from '../components/bot-card';
export async function renderBotProfilePage(params: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
@ -56,10 +58,15 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void {
container.innerHTML = `
<div class="profile-header">
<h1>${escapeHtml(profile.name)}</h1>
<div class="profile-status ${getStatusClass(profile.health_status)}">
${profile.health_status}
<div class="profile-header-main">
<h1>${escapeHtml(profile.name)}</h1>
<div class="profile-status ${getStatusClass(profile.health_status)}">
${profile.health_status}
</div>
</div>
<button id="share-card-btn" class="btn secondary" title="Download shareable bot profile card">
📇 Share Card
</button>
</div>
<!-- Always visible: core rating -->
@ -148,6 +155,9 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void {
// Activate lazy sections
initLazySections(container);
// Wire share card button
wireShareCardButton(container, profile);
}
function renderRecentMatches(matches: BotProfile['recent_matches']): string {
@ -301,3 +311,29 @@ function escapeHtml(str: string): string {
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Wire up the share card button to download the bot profile card
*/
function wireShareCardButton(container: HTMLElement, profile: BotProfile): void {
const btn = container.querySelector<HTMLButtonElement>('#share-card-btn');
if (!btn) return;
btn.addEventListener('click', async () => {
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = 'Generating...';
try {
await downloadBotCard(profile);
btn.textContent = '✓ Downloaded!';
setTimeout(() => { btn.textContent = originalText; }, 2000);
} catch (error) {
btn.textContent = 'Failed';
console.error('Failed to generate bot card:', error);
setTimeout(() => { btn.textContent = originalText; }, 2000);
} finally {
btn.disabled = false;
}
});
}

View file

@ -1141,3 +1141,212 @@ code {
max-height: 1000px;
}
}
/* ─── Bot Profile Page ───────────────────────────────────────────────────────────── */
.bot-profile-page {
max-width: 900px;
margin: 0 auto;
padding: var(--space-lg);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: var(--space-lg);
}
.breadcrumb a {
color: var(--accent);
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.profile-header-main {
flex: 1;
}
.profile-header h1 {
margin: 0 0 var(--space-sm) 0;
font-size: 2rem;
color: var(--text-primary);
}
.profile-status {
display: inline-block;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-healthy {
background-color: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.status-unhealthy {
background-color: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.status-unknown {
background-color: rgba(148, 163, 184, 0.15);
color: var(--text-muted);
}
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-md);
}
.profile-section {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
text-align: center;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.meta-list {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-sm) var(--space-lg);
font-size: 0.875rem;
}
.meta-list dt {
color: var(--text-muted);
}
.meta-list dd {
color: var(--text-primary);
}
.rating-display {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.rating-main {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
}
.rating-dev {
font-size: 1rem;
color: var(--text-muted);
}
.rating-chart {
margin-top: var(--space-md);
}
.rating-sparkline {
width: 100%;
height: 60px;
}
.rating-range {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: var(--space-xs);
}
/* Match items in history */
.match-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-sm);
margin-bottom: var(--space-xs);
background-color: var(--bg-primary);
}
.match-result {
font-weight: 600;
min-width: 24px;
text-align: center;
}
.match-won .match-result {
color: var(--success);
}
.match-lost .match-result {
color: var(--error);
}
.match-opponent {
flex: 1;
color: var(--text-primary);
}
.match-score {
font-family: var(--font-mono);
color: var(--text-muted);
}
.enriched-badge {
font-size: 0.65rem;
padding: 2px var(--space-sm);
background-color: rgba(168, 85, 247, 0.2);
color: var(--player5);
border-radius: var(--radius-sm);
font-weight: 600;
}
.match-list-rest {
margin-top: var(--space-sm);
}
.match-list-rest.expanded {
animation: expand-in 200ms ease-out;
}
.show-more-matches {
margin-top: var(--space-sm);
width: 100%;
}

View file

@ -408,16 +408,25 @@
/* Bot profile mobile */
.profile-header {
flex-direction: column;
align-items: stretch;
text-align: center;
gap: var(--space-sm);
}
.profile-header-main {
text-align: center;
}
#share-card-btn {
width: 100%;
}
.profile-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
}
/* Bot cards mobile */