Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions components/PlayerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */}
<div
style={{
...at(50, 53.66),
transform: "translateX(-50%)",
fontFamily: FONT_BOLD,
fontSize: "13cqw",
fontWeight: 700,
whiteSpace: "nowrap",
color: ink,
...at(0, 53.66),
width: "100%",
textAlign: "center",
}}
>
{displayName}
<span
style={{
display: "inline-block",
fontFamily: FONT_BOLD,
fontSize: `${getFitFontSize(displayName)}cqw`,
fontWeight: 700,
whiteSpace: "nowrap",
color: ink,
}}
>
{displayName}
</span>
</div>

{/* six stats */}
Expand Down
8 changes: 5 additions & 3 deletions lib/og/renderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -176,9 +176,11 @@ export function cardTree(card: Card, assets: CardAssets, w: number) {
{/* top language logo */}
{logo && <img alt="" src={logo} style={{ ...at(19.06, 42.25), width: "11.875%", height: "7.5%", objectFit: "contain" }} />}

{/* name — centered across the card */}
{/* name — centered across the card. fontSize steps down with display-name
length via lib/text.getFitFontSize, mirroring <PlayerCard> exactly; Satori has
no DOM to measure so the length-based heuristic is shared across both paths. */}
<div style={{ position: "absolute", left: 0, top: "53.66%", width: "100%", display: "flex", justifyContent: "center" }}>
<div style={{ display: "flex", fontSize: cqw(13), fontWeight: 700, color: ink, whiteSpace: "nowrap" }}>{displayName}</div>
<div style={{ display: "flex", fontSize: cqw(getFitFontSize(displayName)), fontWeight: 700, color: ink, whiteSpace: "nowrap" }}>{displayName}</div>
</div>

{/* six stat values + labels (flat absolute children so the % positions
Expand Down
19 changes: 19 additions & 0 deletions lib/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
66 changes: 66 additions & 0 deletions tests/text.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});