diff --git a/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts b/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts index 146de9654..1c6e93586 100644 --- a/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts +++ b/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts @@ -27,7 +27,6 @@ describe("renderProfileEmbedSvg", () => { expect(svg).toContain(" { expect(svg).toContain('width="460"'); expect(svg).toContain('height="162"'); - expect(svg).toContain("README EMBED"); + expect(svg).toContain("Tokscale Stats"); expect(svg).toContain("@octocat"); - expect(svg).toContain('stop-color="#F6FAFF"'); + expect(svg).toContain('stop-color="#FFFFFF"'); expect(svg).not.toContain("Submissions"); }); @@ -69,13 +68,13 @@ describe("renderProfileEmbedSvg", () => { expect(costSvg).toContain("RANK · COST"); }); - it("uses gradient tokens, green cost, and medal rank colors", () => { + it("uses gradient tokens, green cost, and rank-specific colors", () => { const svg = renderProfileEmbedSvg(mockStats); - expect(svg).toContain('id="tokens-gradient"'); - expect(svg).toContain('fill="url(#tokens-gradient)"'); - expect(svg).toContain('fill="#53D18C"'); - expect(svg).toContain('fill="#D97706"'); + expect(svg).toContain('id="token-grad"'); + expect(svg).toContain('fill="url(#token-grad)"'); + expect(svg).toContain('fill="#3FB950"'); + expect(svg).toContain('fill="#DA7E1A"'); }); it("uses gold color for rank #1", () => { @@ -83,17 +82,19 @@ describe("renderProfileEmbedSvg", () => { ...mockStats, stats: { ...mockStats.stats, rank: 1 }, }); - expect(svg).toContain('fill="#EAB308"'); + expect(svg).toContain('fill="#E3B341"'); }); - it("renders branded gradient surfaces for the refreshed card", () => { + it("renders redesigned card structure with brand icon and accent bars", () => { const svg = renderProfileEmbedSvg(mockStats); - expect(svg).toContain('id="card-bg"'); - expect(svg).toContain('id="shell-bg"'); - expect(svg).toContain('id="header-bg"'); - expect(svg).toContain('id="metric-sheen"'); - expect(svg).toContain('filter="url(#soft-glow)"'); + expect(svg).toContain('id="bg"'); + expect(svg).toContain('id="glow"'); + expect(svg).toContain('id="divider-grad"'); + expect(svg).toContain('id="acc-tokens"'); + expect(svg).toContain('id="acc-cost"'); + expect(svg).toContain('id="acc-rank"'); + expect(svg).toContain('clip-path="url(#card-clip)"'); }); it("escapes XML in user-provided text", () => { @@ -122,7 +123,7 @@ describe("renderProfileEmbedSvg", () => { const displayNameTag = svg.match(/]*>The Octocat<\/text>/); expect(displayNameTag).toBeTruthy(); const x = Number(displayNameTag![1]); - expect(x).toBeGreaterThanOrEqual(20 + 18 + 8 * 9 + 8); + expect(x).toBeGreaterThanOrEqual(24 + 8 * 17 * 0.6 + 8); }); it("hides display name when username is too long to leave room", () => { @@ -170,6 +171,82 @@ describe("renderProfileEmbedSvg", () => { expect(compactSvg).toContain(expectedDisplayName); expect(defaultSvg).toContain(expectedDisplayName); }); + + it("auto-scales font size for very long token values", () => { + const svg = renderProfileEmbedSvg({ + ...mockStats, + stats: { ...mockStats.stats, totalTokens: 15726314363 }, + }); + + expect(svg).toContain("15,726,314,363"); + const valueTag = svg.match(/font-size="(\d+)"[^>]*font-weight="800"[^>]*>15,726,314,363/); + expect(valueTag).toBeTruthy(); + const fontSize = Number(valueTag![1]); + expect(fontSize).toBeLessThan(28); + expect(fontSize).toBeGreaterThanOrEqual(14); + }); +}); + +describe("renderProfileEmbedSvg with contributions graph", () => { + const mockContributions = [ + { date: "2026-01-15", intensity: 0 as const }, + { date: "2026-02-10", intensity: 2 as const }, + { date: "2026-02-20", intensity: 4 as const }, + ]; + + it("extends card height when contributions provided", () => { + const withoutGraph = renderProfileEmbedSvg(mockStats); + const withGraph = renderProfileEmbedSvg(mockStats, { contributions: mockContributions }); + + expect(withoutGraph).toContain('height="186"'); + const heightMatch = withGraph.match(/height="(\d+)"/); + expect(heightMatch).toBeTruthy(); + expect(Number(heightMatch![1])).toBeGreaterThan(186); + }); + + it("renders GitHub-style contribution grid cells", () => { + const svg = renderProfileEmbedSvg(mockStats, { contributions: mockContributions }); + + expect(svg).toContain('rx="2"'); + expect(svg).toContain('fill="#161B22"'); + expect(svg).toContain("Less"); + expect(svg).toContain("More"); + }); + + it("renders day labels (Mon, Wed, Fri)", () => { + const svg = renderProfileEmbedSvg(mockStats, { contributions: mockContributions }); + + expect(svg).toContain(">Mon<"); + expect(svg).toContain(">Wed<"); + expect(svg).toContain(">Fri<"); + }); + + it("renders month labels", () => { + const svg = renderProfileEmbedSvg(mockStats, { contributions: mockContributions }); + + expect(svg).toContain(">Jan<"); + }); + + it("ignores contributions in compact mode", () => { + const svg = renderProfileEmbedSvg(mockStats, { compact: true, contributions: mockContributions }); + + expect(svg).toContain('height="162"'); + expect(svg).not.toContain("Less"); + expect(svg).not.toContain("More"); + }); + + it("uses light theme graph colors", () => { + const svg = renderProfileEmbedSvg(mockStats, { theme: "light", contributions: mockContributions }); + + expect(svg).toContain('fill="#EBEDF0"'); + }); + + it("does not render graph when contributions is null", () => { + const svg = renderProfileEmbedSvg(mockStats, { contributions: null }); + + expect(svg).toContain('height="186"'); + expect(svg).not.toContain("Less"); + }); }); describe("renderProfileEmbedErrorSvg", () => { @@ -177,10 +254,9 @@ describe("renderProfileEmbedErrorSvg", () => { const svg = renderProfileEmbedErrorSvg("User ", { theme: "light" }); expect(svg).toContain("Tokscale Stats"); - expect(svg).toContain("README EMBED"); expect(svg).toContain("User <unknown>"); expect(svg).not.toContain("User "); expect(svg).toContain("family=Figtree"); - expect(svg).toContain('id="error-bg"'); + expect(svg).toContain('id="err-bg"'); }); }); diff --git a/packages/frontend/src/app/api/embed/[username]/svg/route.ts b/packages/frontend/src/app/api/embed/[username]/svg/route.ts index 79396d811..fe7864ee9 100644 --- a/packages/frontend/src/app/api/embed/[username]/svg/route.ts +++ b/packages/frontend/src/app/api/embed/[username]/svg/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getUserEmbedStats, type EmbedSortBy } from "@/lib/embed/getUserEmbedStats"; +import { getUserEmbedStats, getUserEmbedContributions, type EmbedSortBy } from "@/lib/embed/getUserEmbedStats"; import { renderProfileEmbedErrorSvg, renderProfileEmbedSvg, @@ -23,6 +23,11 @@ function parseSort(searchParams: URLSearchParams): EmbedSortBy { return value === "cost" ? "cost" : "tokens"; } +function parseGraph(searchParams: URLSearchParams): boolean { + const value = searchParams.get("graph"); + return value === "1" || value === "true"; +} + function createSvgResponse(svg: string, init?: { status?: number; cacheControl?: string }) { return new NextResponse(svg, { status: init?.status ?? 200, @@ -47,6 +52,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const theme = parseTheme(searchParams); const compact = parseCompact(searchParams); const sortBy = parseSort(searchParams); + const showGraph = parseGraph(searchParams); if (!isValidGitHubUsername(username)) { const svg = renderProfileEmbedErrorSvg("Invalid username format", { theme, compact: true }); @@ -64,11 +70,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return createSvgResponse(svg, { status: 200 }); } + const contributions = showGraph ? await getUserEmbedContributions(username) : null; + const svg = renderProfileEmbedSvg(data, { theme, compact, compactNumbers: compact, sortBy, + contributions, }); console.info("[embed-svg] success", { @@ -78,6 +87,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { compact, sortBy, theme, + graph: showGraph, }); return createSvgResponse(svg); diff --git a/packages/frontend/src/lib/embed/getUserEmbedStats.ts b/packages/frontend/src/lib/embed/getUserEmbedStats.ts index 0678af8e4..742fcf617 100644 --- a/packages/frontend/src/lib/embed/getUserEmbedStats.ts +++ b/packages/frontend/src/lib/embed/getUserEmbedStats.ts @@ -1,9 +1,14 @@ import { unstable_cache } from "next/cache"; -import { db, users, submissions } from "@/lib/db"; -import { eq, sql } from "drizzle-orm"; +import { db, users, submissions, dailyBreakdown } from "@/lib/db"; +import { eq, sql, and, gte } from "drizzle-orm"; export type EmbedSortBy = "tokens" | "cost"; +export interface EmbedContributionDay { + date: string; + intensity: 0 | 1 | 2 | 3 | 4; +} + export interface UserEmbedStats { user: { id: string; @@ -91,3 +96,52 @@ export function getUserEmbedStats(username: string, sortBy: EmbedSortBy = "token } )(); } + +async function fetchUserEmbedContributions(username: string): Promise { + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (!user) return null; + + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + const cutoff = oneYearAgo.toISOString().split("T")[0]; + + const rows = await db + .select({ date: dailyBreakdown.date, cost: dailyBreakdown.cost }) + .from(dailyBreakdown) + .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) + .where(and(eq(submissions.userId, user.id), gte(dailyBreakdown.date, cutoff))) + .orderBy(dailyBreakdown.date); + + if (rows.length === 0) return []; + + const dayMap = new Map(); + for (const row of rows) { + dayMap.set(row.date, (dayMap.get(row.date) || 0) + (Number(row.cost) || 0)); + } + + const costs = Array.from(dayMap.values()).filter((c) => c > 0); + const maxCost = Math.max(...costs, 0); + + return Array.from(dayMap.entries()).map(([date, cost]) => ({ + date, + intensity: ( + maxCost === 0 ? 0 : cost === 0 ? 0 : cost <= maxCost * 0.25 ? 1 : cost <= maxCost * 0.5 ? 2 : cost <= maxCost * 0.75 ? 3 : 4 + ) as 0 | 1 | 2 | 3 | 4, + })); +} + +export function getUserEmbedContributions(username: string): Promise { + return unstable_cache( + () => fetchUserEmbedContributions(username), + [`embed-contrib:${username}`], + { + tags: [`user:${username}`, `embed-contrib:${username}`], + revalidate: 60, + } + )(); +} diff --git a/packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts b/packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts index f7df23517..784f121bc 100644 --- a/packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts +++ b/packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts @@ -1,5 +1,5 @@ -import type { UserEmbedStats } from "./getUserEmbedStats"; -import { escapeXml, formatCompact, formatNumber, formatCurrency } from "../format"; +import type { UserEmbedStats, EmbedContributionDay } from "./getUserEmbedStats"; +import { escapeXml, formatNumber, formatCurrency } from "../format"; export type EmbedTheme = "dark" | "light"; export type EmbedSortBy = "tokens" | "cost"; @@ -9,69 +9,106 @@ export interface RenderProfileEmbedOptions { compact?: boolean; compactNumbers?: boolean; sortBy?: EmbedSortBy; + contributions?: EmbedContributionDay[] | null; } type ThemePalette = { - background: string; - backgroundEnd: string; - shell: string; - shellEnd: string; - surface: string; - surfaceAlt: string; + bgStart: string; + bgEnd: string; border: string; - borderSoft: string; + glowColor: string; + glowOpacity: number; + metricBg: string; + metricBorder: string; title: string; text: string; muted: string; - accentSoft: string; - accentGlow: string; - tokenGradientStart: string; - tokenGradientMid: string; - tokenGradientEnd: string; - costAccent: string; - highlight: string; + brand: string; + tokenStart: string; + tokenEnd: string; + cost: string; + rankGold: string; + rankSilver: string; + rankBronze: string; + rankDefault: string; + badgeBg: string; + badgeBorder: string; + badgeText: string; + divider: string; + accentTokens: string; + accentCost: string; + accentRank: string; + graphGrade0: string; + graphGrade1: string; + graphGrade2: string; + graphGrade3: string; + graphGrade4: string; }; const THEMES: Record = { dark: { - background: "#10121C", - backgroundEnd: "#080B12", - shell: "#141A21", - shellEnd: "#111113", - surface: "#111822", - surfaceAlt: "#0D141D", - border: "#1E2733", - borderSoft: "rgba(133, 202, 255, 0.14)", - title: "#FFFFFF", - text: "#E6EDF7", - muted: "#7691B7", - accentSoft: "#85CAFF", - accentGlow: "rgba(22, 154, 255, 0.22)", - tokenGradientStart: "#169AFF", - tokenGradientMid: "#9FD4FB", - tokenGradientEnd: "#B9DFF8", - costAccent: "#53D18C", - highlight: "rgba(83, 209, 243, 0.18)", + bgStart: "#0D1117", + bgEnd: "#010409", + border: "#30363D", + glowColor: "#388BFD", + glowOpacity: 0.07, + metricBg: "rgba(22,27,34,0.6)", + metricBorder: "rgba(48,54,61,0.6)", + title: "#F0F6FC", + text: "#E6EDF3", + muted: "#8B949E", + brand: "#58A6FF", + tokenStart: "#58A6FF", + tokenEnd: "#A5D6FF", + cost: "#3FB950", + rankGold: "#E3B341", + rankSilver: "#8B949E", + rankBronze: "#DA7E1A", + rankDefault: "#58A6FF", + badgeBg: "rgba(56,139,253,0.08)", + badgeBorder: "rgba(56,139,253,0.25)", + badgeText: "#58A6FF", + divider: "#30363D", + accentTokens: "#58A6FF", + accentCost: "#3FB950", + accentRank: "#D29922", + graphGrade0: "#161B22", + graphGrade1: "#0E4429", + graphGrade2: "#006D32", + graphGrade3: "#26A641", + graphGrade4: "#39D353", }, light: { - background: "#F6FAFF", - backgroundEnd: "#EAF2FF", - shell: "#FFFFFF", - shellEnd: "#F7FAFF", - surface: "#F8FBFF", - surfaceAlt: "#EEF5FF", - border: "#D6E5F8", - borderSoft: "rgba(22, 154, 255, 0.14)", - title: "#0F172A", - text: "#18324D", - muted: "#5F7FA5", - accentSoft: "#56B9FF", - accentGlow: "rgba(22, 154, 255, 0.12)", - tokenGradientStart: "#0073FF", - tokenGradientMid: "#53B9FF", - tokenGradientEnd: "#8FD6FF", - costAccent: "#16804B", - highlight: "rgba(83, 209, 243, 0.12)", + bgStart: "#FFFFFF", + bgEnd: "#F6F8FA", + border: "#D0D7DE", + glowColor: "#0969DA", + glowOpacity: 0.04, + metricBg: "rgba(246,248,250,0.7)", + metricBorder: "rgba(208,215,222,0.55)", + title: "#1F2328", + text: "#1F2328", + muted: "#656D76", + brand: "#0969DA", + tokenStart: "#0969DA", + tokenEnd: "#54AEFF", + cost: "#1A7F37", + rankGold: "#9A6700", + rankSilver: "#656D76", + rankBronze: "#BC4C00", + rankDefault: "#0969DA", + badgeBg: "rgba(9,105,218,0.06)", + badgeBorder: "rgba(9,105,218,0.2)", + badgeText: "#0969DA", + divider: "#D0D7DE", + accentTokens: "#0969DA", + accentCost: "#1A7F37", + accentRank: "#9A6700", + graphGrade0: "#EBEDF0", + graphGrade1: "#9BE9A8", + graphGrade2: "#40C463", + graphGrade3: "#30A14E", + graphGrade4: "#216E39", }, }; @@ -96,18 +133,35 @@ function formatDateLabel(value: string | null): string { }).format(date)} (UTC)`; } -// Approximate average character width for Figtree 15px semibold (weight 600). -// Used to estimate rendered username width for dynamic display-name positioning. -const APPROX_CHAR_WIDTH_15_SEMIBOLD = 9; -// Approximate average character width for Figtree 13px (weight 400). -// Used to estimate rendered display name width for collision detection. +// Approximate character-width ratio for Figtree at various weights. +// Used to estimate rendered text width for dynamic positioning / collision. +const CHAR_WIDTH_RATIO = 0.6; const APPROX_CHAR_WIDTH_13 = 8; function getRankColor(rank: number | null, palette: ThemePalette): string { - if (rank === 1) return "#EAB308"; - if (rank === 2) return "#94A3B8"; - if (rank === 3) return "#D97706"; - return palette.accentSoft; + if (rank === 1) return palette.rankGold; + if (rank === 2) return palette.rankSilver; + if (rank === 3) return palette.rankBronze; + return palette.rankDefault; +} + +/** + * Auto-scale font size so rendered text fits within maxWidth. + * Uses a conservative character-width estimate to prevent overflow. + */ +function fitValueFontSize(text: string, maxWidth: number, baseSize: number): number { + const estWidth = text.length * baseSize * CHAR_WIDTH_RATIO; + if (estWidth <= maxWidth) return baseSize; + return Math.max(Math.ceil(baseSize * 0.5), Math.floor(baseSize * (maxWidth / estWidth))); +} + +function brandIcon(x: number, baselineY: number, color: string): string { + const top = baselineY - 12; + return [ + ``, + ``, + ``, + ].join(""); } function metricCard(args: { @@ -117,41 +171,119 @@ function metricCard(args: { height: number; label: string; value: string; + accentId: string; valueFill: string; palette: ThemePalette; compact: boolean; }): string { - const { x, y, width, height, label, value, valueFill, palette, compact } = args; - const labelY = y + 24; - const valueY = compact ? y + 50 : y + 56; - const valueSize = compact ? 24 : 28; + const { x, y, width, height, label, value, accentId, valueFill, palette, compact } = args; + const labelY = y + (compact ? 20 : 24); + const baseValueSize = compact ? 22 : 28; + const availableWidth = width - 28; + const valueSize = fitValueFontSize(value, availableWidth, baseValueSize); + const valueY = compact ? y + height - 12 : y + height - 14; return [ - ``, - ``, - `${escapeXml(label)}`, - `${escapeXml(value)}`, + ``, + ``, + `${escapeXml(label)}`, + `${escapeXml(value)}`, ].join(""); } +const GRAPH_CELL = 9; +const GRAPH_GAP = 2; +const GRAPH_STRIDE = GRAPH_CELL + GRAPH_GAP; +const GRAPH_DAY_LABEL_W = 26; +const GRAPH_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function renderContributionGrid( + contributions: EmbedContributionDay[], + palette: ThemePalette, + x: number, + y: number, +): string { + const gridX = x + GRAPH_DAY_LABEL_W; + const intensityMap = new Map(); + for (const c of contributions) intensityMap.set(c.date, c.intensity); + + const colors = [palette.graphGrade0, palette.graphGrade1, palette.graphGrade2, palette.graphGrade3, palette.graphGrade4]; + + const now = new Date(); + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const start = new Date(today); + start.setUTCFullYear(start.getUTCFullYear() - 1); + start.setUTCDate(start.getUTCDate() + 1); + start.setUTCDate(start.getUTCDate() - start.getUTCDay()); + + const diffDays = Math.ceil((today.getTime() - start.getTime()) / 86_400_000); + const numWeeks = Math.ceil((diffDays + 1) / 7); + + const monthLabelY = y + 9; + const gridTopY = y + 14; + let svg = ""; + + let lastMonth = -1; + for (let w = 0; w < numWeeks; w++) { + const d = new Date(start); + d.setUTCDate(d.getUTCDate() + w * 7); + const m = d.getUTCMonth(); + if (m !== lastMonth) { + lastMonth = m; + svg += `${GRAPH_MONTH_NAMES[m]}`; + } + } + + const dayLabels: [number, string][] = [[1, "Mon"], [3, "Wed"], [5, "Fri"]]; + for (const [row, label] of dayLabels) { + svg += `${label}`; + } + + for (let w = 0; w < numWeeks; w++) { + for (let d = 0; d < 7; d++) { + const date = new Date(start); + date.setUTCDate(date.getUTCDate() + w * 7 + d); + if (date > today) continue; + const dateStr = date.toISOString().split("T")[0]; + const intensity = intensityMap.get(dateStr) ?? 0; + svg += ``; + } + } + + const legendY = gridTopY + 7 * GRAPH_STRIDE + 6; + const gridRightX = gridX + (numWeeks - 1) * GRAPH_STRIDE + GRAPH_CELL; + const legendW = 28 + 5 * GRAPH_STRIDE + 28; + const legendX = gridRightX - legendW; + svg += `Less`; + let lx = legendX + 28; + for (let i = 0; i < 5; i++) { + svg += ``; + lx += GRAPH_STRIDE; + } + svg += `More`; + + return svg; +} + function renderProfileCardSvg(data: UserEmbedStats, options: RenderProfileEmbedOptions = {}): string { const theme: EmbedTheme = options.theme === "light" ? "light" : "dark"; const compact = options.compact ?? false; const compactNumbers = options.compactNumbers ?? false; const sortBy: EmbedSortBy = options.sortBy === "cost" ? "cost" : "tokens"; + const contributions = (!compact && options.contributions) ? options.contributions : null; const palette = THEMES[theme]; const width = compact ? 460 : 680; - const height = compact ? 162 : 186; - const rx = 18; - const paddingX = compact ? 18 : 20; - const headerY = compact ? 18 : 20; - const headerHeight = compact ? 58 : 68; - const metricsY = headerY + headerHeight + 12; - const footerY = height - 16; - const eyebrowY = headerY + (compact ? 18 : 24); - const titleY = headerY + (compact ? 38 : 46); - const userY = headerY + (compact ? 54 : 64); + const height = (compact ? 162 : 186) + (contributions ? 120 : 0); + const rx = 16; + const px = compact ? 18 : 24; + + const brandY = compact ? 26 : 30; + const usernameY = compact ? 44 : 52; + const dividerY = compact ? 54 : 64; + const metricsY = compact ? 64 : 76; + const metricH = compact ? 58 : 68; + const footerY = height - (compact ? 14 : 16); const username = `@${data.user.username}`; const displayNameRaw = data.user.displayName; @@ -160,117 +292,119 @@ function renderProfileCardSvg(data: UserEmbedStats, options: RenderProfileEmbedO const cost = formatCurrency(data.stats.totalCost, compactNumbers); const rank = data.stats.rank ? `#${data.stats.rank}` : "N/A"; const updated = escapeXml(formatDateLabel(data.stats.updatedAt)); - const rankLabel = compact ? `Rank · ${sortBy === "cost" ? "Cost" : "Tokens"}` : `Rank (${sortBy === "cost" ? "Cost" : "Tokens"})`; - - const usernameEstimatedWidth = username.length * APPROX_CHAR_WIDTH_15_SEMIBOLD; - const displayNameX = paddingX + 18 + usernameEstimatedWidth + 8; - const badgeWidth = compact ? 92 : 110; - const badgeX = width - paddingX - badgeWidth; - const displayNameEstimatedWidth = displayNameRaw ? displayNameRaw.length * APPROX_CHAR_WIDTH_13 : 0; - const showDisplayName = Boolean(displayName) && displayNameX + displayNameEstimatedWidth < badgeX - 12; - - const metricsGap = 12; - const metricsWidth = width - paddingX * 2; - const metricWidth = (metricsWidth - metricsGap * 2) / 3; - const metricHeight = compact ? 56 : 64; + const rankLabel = compact + ? `Rank · ${sortBy === "cost" ? "Cost" : "Tokens"}` + : `Rank (${sortBy === "cost" ? "Cost" : "Tokens"})`; + + const badgeText = sortBy === "cost" ? "RANK · COST" : "RANK · TOKENS"; + const badgeWidth = compact ? 88 : 106; + const badgeX = width - px - badgeWidth; + const badgeH = compact ? 22 : 24; + const badgeY = compact ? 14 : 16; + const badgeRx = badgeH / 2; + + const usernameFontSize = compact ? 15 : 17; + const usernameEstWidth = username.length * usernameFontSize * CHAR_WIDTH_RATIO; + const displayNameX = Math.round(px + usernameEstWidth + 8); + const displayNameEstWidth = displayNameRaw ? displayNameRaw.length * APPROX_CHAR_WIDTH_13 : 0; + const showDisplayName = Boolean(displayName) && displayNameX + displayNameEstWidth < badgeX - 12; + + const metricsGap = compact ? 8 : 10; + const metricsW = width - px * 2; + const metricW = (metricsW - metricsGap * 2) / 3; const rankColor = getRankColor(data.stats.rank, palette); + const rankAccent = getRankColor(data.stats.rank, palette); return ` - - - - - - - + + + - - - + + + + + + + - - - + + + + - - - + + + - - - - + + + - - - - + + + - - - - - - - - - - - - - - README EMBED - Tokscale Stats - ${escapeXml(username)} + + + + ${brandIcon(px, brandY, palette.brand)} + Tokscale Stats + ${escapeXml(username)} ${ showDisplayName - ? `${displayName}` + ? `${displayName}` : "" } - - ${sortBy === "cost" ? "RANK · COST" : "RANK · TOKENS"} + + ${badgeText} + ${metricCard({ - x: paddingX, + x: px, y: metricsY, - width: metricWidth, - height: metricHeight, + width: metricW, + height: metricH, label: "Tokens", value: tokens, - valueFill: "url(#tokens-gradient)", + accentId: "acc-tokens", + valueFill: "url(#token-grad)", palette, compact, })} ${metricCard({ - x: paddingX + metricWidth + metricsGap, + x: px + metricW + metricsGap, y: metricsY, - width: metricWidth, - height: metricHeight, + width: metricW, + height: metricH, label: "Cost", value: cost, - valueFill: palette.costAccent, + accentId: "acc-cost", + valueFill: palette.cost, palette, compact, })} ${metricCard({ - x: paddingX + metricWidth * 2 + metricsGap * 2, + x: px + metricW * 2 + metricsGap * 2, y: metricsY, - width: metricWidth, - height: metricHeight, + width: metricW, + height: metricH, label: rankLabel, value: rank, + accentId: "acc-rank", valueFill: rankColor, palette, compact, })} - ${updated} - tokscale.ai/u/${escapeXml( + ${contributions ? renderContributionGrid(contributions, palette, px, metricsY + metricH + 12) : ""} + ${updated} + tokscale.ai/u/${escapeXml( data.user.username )} `; @@ -290,32 +424,32 @@ export function renderProfileEmbedErrorSvg( const theme: EmbedTheme = options.theme === "light" ? "light" : "dark"; const palette = THEMES[theme]; const safeMessage = escapeXml(message); + const width = 540; + const height = 120; + const rx = 16; + const px = 24; return ` - + - - - - - - - + + + - - - - + + + + - - - - - README EMBED - Tokscale Stats - ${safeMessage} - Try checking the username or submitting usage first. + + + ${brandIcon(px, 30, palette.brand)} + Tokscale Stats + + ${safeMessage} + Try checking the username or submitting usage first. + tokscale.ai `; }