From d16a54899ec0358741be2c8c3497318d1b5b7df4 Mon Sep 17 00:00:00 2001 From: rgarciar1931 <296995245+rgarciar1931@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:25:58 +0200 Subject: [PATCH] feat(card): fit displayName by length across live card + Satori render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long GitHub display names (e.g. unbroken uppercase tokens like "ABCDEFGHIJKLMNOPQRS", 19+ chars) overflowed the FUT card's visible band at the previous fixed 13cqw font-size. Add a pure length-aware getFitFontSize(displayName) step function in lib/text.ts (cqw: 13 → 11.5 → 10 → 9 → 8.2 → 7.5, keyed on the already-uppercased display-name length). Replace the PlayerCard name wrapper's left:50% + translateX(-50%) layout with a width:100% + text-align:center outer div carrying an inner display:inline-block span that reads the helper, and consume the same helper from the Satori OG render in lib/og/renderCard.tsx so the export and OG image are visually identical to the screen without DOM measurement on the server. --- components/PlayerCard.tsx | 33 ++++++++++++++------ lib/og/renderCard.tsx | 8 +++-- lib/text.ts | 19 +++++++++++ tests/text.test.ts | 66 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 tests/text.test.ts diff --git a/components/PlayerCard.tsx b/components/PlayerCard.tsx index 114930d..c89933e 100644 --- a/components/PlayerCard.tsx +++ b/components/PlayerCard.tsx @@ -3,7 +3,7 @@ import { memo, type CSSProperties } from "react"; import type { Card, StatKey } from "@/lib/scoring/types"; import { languageLogoUrl } from "@/lib/github/languages"; -import { cardDisplayName } from "@/lib/text"; +import { cardDisplayName, getFitFontSize } from "@/lib/text"; import { resolveCardTheme } from "./finishTheme"; // Faithful port of the Python FUT generator's 540×820 layout. Positions are @@ -260,19 +260,32 @@ function PlayerCard({ card }: { card: Card }) { /> )} - {/* name (centered across the card) */} + {/* name — centered across the card. The wrapper is 100% wide so the inner + span (inline-block + nowrap) centers verbatim via text-align, and the + font-size steps down with display-name length so 19+ character names (e.g. + "ABCDEFGHIJKLMNOPQRS") stay inside the visible band (~83% of card, + framed by H_LINES[2]). The step function lives next to cardDisplayName + in lib/text.ts so live card, html-to-image export, and Satori share + identical sizing. */}
- {displayName} + + {displayName} +
{/* six stats */} diff --git a/lib/og/renderCard.tsx b/lib/og/renderCard.tsx index f3e2e92..4f4278a 100644 --- a/lib/og/renderCard.tsx +++ b/lib/og/renderCard.tsx @@ -4,7 +4,7 @@ import { join } from "node:path"; import type { Card, StatKey } from "@/lib/scoring/types"; import { resolveCardTheme } from "@/components/finishTheme"; import { languageLogoUrl } from "@/lib/github/languages"; -import { cardDisplayName } from "@/lib/text"; +import { cardDisplayName, getFitFontSize } from "@/lib/text"; import { loadCardFonts } from "./card"; // Server-side re-creation of the in-app PlayerCard (components/PlayerCard.tsx), @@ -176,9 +176,11 @@ export function cardTree(card: Card, assets: CardAssets, w: number) { {/* top language logo */} {logo && } - {/* name — centered across the card */} + {/* name — centered across the card. fontSize steps down with display-name + length via lib/text.getFitFontSize, mirroring exactly; Satori has + no DOM to measure so the length-based heuristic is shared across both paths. */}
-
{displayName}
+
{displayName}
{/* six stat values + labels (flat absolute children so the % positions diff --git a/lib/text.ts b/lib/text.ts index 64020e0..91756a6 100644 --- a/lib/text.ts +++ b/lib/text.ts @@ -30,3 +30,22 @@ export function cardDisplayName(name: string): string { surname.length > MAX_SURNAME_CHARS || rest.length > MAX_SURNAME_WORDS; return tooBig ? first : surname; } + +// Length-aware font size (in cqw of the card width) for the on-card display name. +// The base 13cqw size fits ~10-12 uppercase DINPro-CondBold chars; longer names +// overflow the visible card band, so we step the size down by string length and +// cap the visible band at ~90% of the card width (the under-name separator spans +// 16.67% → 83.33%, so the text sits inside that range). +// +// We pass the *already uppercased* display name (the live card and the OG render +// both uppercase before measuring). Satori has no DOM to measure, so this pure +// helper is shared between both rendering paths and keeps them visually identical. +export function getFitFontSize(displayName: string): number { + const len = displayName.length; + if (len <= 10) return 13; + if (len <= 13) return 11.5; + if (len <= 16) return 10; + if (len <= 19) return 9; + if (len <= 22) return 8.2; + return 7.5; +} diff --git a/tests/text.test.ts b/tests/text.test.ts new file mode 100644 index 0000000..abbc98b --- /dev/null +++ b/tests/text.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { cardDisplayName, getFitFontSize } from "@/lib/text"; + +// The on-card name size steps down with displayName.length (uppercase DINPro-CondBold). +// These thresholds are shared between the live PlayerCard, html-to-image export, and +// the Satori render (lib/og/renderCard.tsx); we test the contract here so a tweak in +// one place can't silently desync the others. +describe("getFitFontSize", () => { + it("keeps the base 13cqw for short names that comfortably fit the band", () => { + expect(getFitFontSize("YOUNESFDJ")).toBe(13); // 8 chars + expect(getFitFontSize("LINUS")).toBe(13); // 5 chars + expect(getFitFontSize("ABCDEFGHIJ")).toBe(13); // 10 chars (boundary) + }); + + it("steps down at each length threshold so longer names stay inside ~83% of card width", () => { + expect(getFitFontSize("ABCDEFGHIJKLM")).toBe(11.5); // 13 chars (boundary) + expect(getFitFontSize("ABCDEFGHIJKLMNOP")).toBe(10); // 16 chars (boundary) + expect(getFitFontSize("ABCDEFGHIJKLMNOPQRS")).toBe(9); // 19 chars (boundary) + }); + + it("keeps stepping down for very long names but never below the readable floor", () => { + expect(getFitFontSize("VERYLONGGAMERTAGHERE")).toBe(8.2); // 22 chars (boundary) + expect(getFitFontSize("ABSURDLYSUPERLONGLOGINSHOULDNTHAPPENBUT")).toBe(7.5); // >22 chars + }); + + it("is monotonically non-increasing as length grows", () => { + const sizes = [ + getFitFontSize("A"), + getFitFontSize("AB"), + getFitFontSize("ABCDEFGHIJ"), + getFitFontSize("ABCDEFGHIJKLM"), + getFitFontSize("ABCDEFGHIJKLMNOP"), + getFitFontSize("ABCDEFGHIJKLMNOPQRS"), + getFitFontSize("ABCDEFGHIJKLMNOPQRSTUV"), + getFitFontSize("ABCDEFGHIJKLMNOPQRSTUVWXYZABCD"), + ]; + for (let i = 1; i < sizes.length; i++) { + expect(sizes[i]).toBeLessThanOrEqual(sizes[i - 1]); + } + }); +}); + +// cardDisplayName is already covered indirectly via the share/engine/duel tests; +// we keep one regression test here so changes to the helper are caught next to +// getFitFontSize (its main downstream consumer). +describe("cardDisplayName", () => { + it("collapses to the surname when it's short enough (and the full name is not in band)", () => { + expect(cardDisplayName("Linus Torvalds")).toBe("Torvalds"); + }); + + it("leaves single-token oversize names verbatim (no spaces to split on)", () => { + // The whole name is one big token so the helper returns it unchanged. + expect(cardDisplayName("reallylongsinglewordusernameforthelongtestcase")).toBe( + "reallylongsinglewordusernameforthelongtestcase", + ); + }); + + it("collapses to the first name when the surname is too long or has too many words", () => { + expect(cardDisplayName("Younes Ferradji Teffahi")).toBe("Younes"); // surname too long + }); + + it("falls back to the joined surname when it's short enough", () => { + // surname must be <= MAX_SURNAME_CHARS (13) chars including spaces + expect(cardDisplayName("Ada Smith Doe")).toBe("Smith Doe"); // 9 chars + }); +});