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
+ });
+});