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:
parent
5e7ade8d78
commit
b93ea06d4c
4 changed files with 703 additions and 4 deletions
445
web/src/components/bot-card.ts
Normal file
445
web/src/components/bot-card.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue