Skip to content
Draft
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
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,67 @@ Every card walks out in a finish:

<br/>

## 🏆 &nbsp;Achievements & Awards

Standout cards earn trophies, shown on the scout report and carried into duels like a boxer's belts — a duel showcases what each player brings, it never awards anything itself. Click a trophy for its story: every win is attached to a year (or a World Cup edition) with the reason it was given. Rendered from pre-baked rotating sprites (no 3D runtime — see `scripts/bake-award-sprites.ts`):

| Award | Won for |
| :---: | :--- |
| **Ballon d'Or** | A calendar year that towers over the rest of your career — judged against your own history (log-scaled across commits, PRs, reviews and issues, so volume spam can't buy it). Repeatable, but every extra one is harder to win: up to 3, and Icons can take a 4th. |
| **WC Golden Ball** | Best player of a World Cup — rising above your own pace while a tournament ran (2010 → 2026, era-adjusted: shipping on GitHub in 2010 already counted). One per edition. |
| **Golden Boot** | The top scorer, held on current form — elite Shooting (`SHO` 80+) from a scoring position (`ST`/`RW`/`CAM`). |
| **World Cup Trophy** | ¡Campeones! Spain won WC26 — a gift carried by every profile whose GitHub location is Spain. |

### The rulebook

Every scout fetches the profile's full history as per-year totals: commits, PRs, reviews, issues and private contributions, for every calendar year since the account was born. Yearly awards are judged on one score per year:

```
yearScore = 4·log(commits) + 3·log(PRs) + 3·log(reviews) + 2·log(issues) + 2·log(private)
```

The logs are the anti-spam mechanism. Each signal saturates: 20,000 commits score 17.2, barely ahead of the 13.2 that 2,000 earn. Meanwhile a year of 800 commits, 80 PRs, 80 reviews and 40 issues scores 26. Breadth collects from every term while volume on one axis stalls. There is no balance requirement, though: a commits-only kernel maintainer still scores on the volume terms alone.

Every trophy except the Spanish gift also requires **overall 80+**. That's a floor, not a driver. It keeps thin profiles off the podium and decides nothing else.

#### Ballon d'Or

Judged against your own career, so it needs at least two years of history. A year wins when it clears all three:

1. **2,000+ raw contributions.** No Ballon d'Or on a quiet year, however spiky it looks against the rest.
2. **Score 16+.** Strong in absolute terms, not just relative ones.
3. **One of two paths:**
- *The spike*: 1.2× your career-median year. One undeniable season in a modest career.
- *The elite*: score 19.5+, ratio waived. A sustained-great career has a huge median no single year can tower over, and those years are exactly what the award exists for.

Repeats climb a ladder on the card's overall: **80** holds one, **87** unlocks a second, **94** a third. Icons can take a fourth, and only for a monster year (score 28+). The current year competes while still in progress. It can only under-score, never over-score, so nothing is ever awarded on a projection.

#### WC Golden Ball

One per World Cup edition. For candidates (overall 80+, activity in a WC year) the scout fetches contributions inside each tournament window and judges them era-adjusted, because GitHub in 2010 was a different sport:

| Edition | Window | The bar |
| :-- | :-- | :-- |
| South Africa 2010 | Jun 11 – Jul 11 | Presence. Shipping on GitHub at all was the distinction. |
| Brazil 2014 | Jun 12 – Jul 13 | A handful of contributions. |
| Russia 2018 | Jun 14 – Jul 15 | A real window, at **1.2×** your usual pace. |
| Qatar 2022 | Nov 20 – Dec 18 | A strong window (~10 contributions/day), at **1.3×** your usual pace. |
| North America 2026 | Jun 11 – Jul 19 | A strong window (~10 contributions/day), at **1.3×** your usual pace. |

The pace test compares your daily rate inside the window to your daily rate across that same year. Tournament form means rising above *your own* pace, not an absolute quota. The current year is paced over its elapsed days, not 365, so a routine mid-year can't masquerade as tournament form against a diluted average.

#### Golden Boot

The one trophy held on current form, not kept for life. No year attached, re-judged on every scout: a scoring position (`ST`/`RW`/`CAM`) and Shooting 80+. Star power *is* the shooting stat, so this is the finisher whose repos keep hitting the net. Lose the form, lose the boot.

#### World Cup Trophy

Spain won WC26, and every profile whose GitHub location reads Spain lifts the trophy with them: bronze cards included, no floors, no other conditions. It keys off the country GitHub reports, decided before the flag picker exists, so switching your flag to 🇪🇸 mints nothing.

The whole cabinet is computed server-side at scout time and cached with the card. Duels never award anything. A corner showcases what each card walked in with.

<br/>

<div align="center">

**Built with** Next.js · TypeScript · Tailwind · Redis
Expand Down
12 changes: 12 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,15 @@
transition-duration: 0.001ms !important;
}
}

/* Ball-cursor toy (components/BallCursor): the real cursor hides only while
the user is actively dribbling — never by default. */
html.gf-ball-cursor,
html.gf-ball-cursor * {
cursor: none !important;
}
html.gf-ball-cursor input,
html.gf-ball-cursor textarea,
html.gf-ball-cursor [contenteditable="true"] {
cursor: text !important;
}
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Bebas_Neue, Inter, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import "./globals.css";
import BallCursor from "@/components/BallCursor";

// Display — ultra-condensed all-caps for the WC26 "tournament" impact.
const display = Bebas_Neue({
Expand Down Expand Up @@ -74,6 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
className={`${display.variable} ${sans.variable} ${mono.variable} ${dinCond.variable} ${dinBold.variable} ${dinMedium.variable} antialiased`}
>
<body>
<BallCursor />
{children}
<Script
defer
Expand Down
22 changes: 16 additions & 6 deletions app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ImageResponse } from "next/og";
import { SAMPLE_CARDS } from "@/lib/github/samples";
import { loadCardAssets, cardTree } from "@/lib/og/renderCard";
import { groupAwards } from "@/lib/awards";
import { loadTrophyStills, trophyShelf } from "@/lib/og/trophies";

// Branded preview for the home page / bare gitfut.com links. Next wires this as
// the default og:image + twitter:image automatically (metadataBase is absolute).
Expand All @@ -15,7 +17,9 @@ const CARD_W = 316; // -> ~480 tall, fits the 630 frame with margin

export default async function Image() {
const card = SAMPLE_CARDS.find((c) => c.login === "torvalds") ?? SAMPLE_CARDS[0];
const assets = await loadCardAssets(card, CARD_W);
const groups = groupAwards(card.awards);
const [assets, stills] = await Promise.all([loadCardAssets(card, CARD_W), loadTrophyStills(groups)]);
const shelf = groups.length ? trophyShelf({ groups, stills, tall: 84, header: null }) : null;

return new ImageResponse(
(
Expand All @@ -24,6 +28,7 @@ export default async function Image() {
width: "100%",
height: "100%",
display: "flex",
position: "relative",
background: "#02001e",
backgroundImage:
"radial-gradient(900px 520px at 22% -12%, rgba(57,211,83,0.20), transparent 60%), radial-gradient(720px 520px at 105% 120%, rgba(212,175,55,0.14), transparent 60%)",
Expand All @@ -39,20 +44,25 @@ export default async function Image() {
GITHUB × WORLD CUP 26
</div>
<div style={{ display: "flex", flexDirection: "column", marginTop: "auto", marginBottom: "auto" }}>
<div style={{ display: "flex", fontSize: 108, fontWeight: 800, lineHeight: 0.95 }}>GET</div>
<div style={{ display: "flex", fontSize: 108, fontWeight: 800, lineHeight: 0.95 }}>
<div style={{ display: "flex", fontSize: 104, fontWeight: 800, lineHeight: 0.95 }}>GET</div>
<div style={{ display: "flex", fontSize: 104, fontWeight: 800, lineHeight: 0.95 }}>
<span>SCOUTED</span>
<span style={{ color: "#39d353" }}>.</span>
</div>
<div style={{ display: "flex", fontSize: 34, color: "#a8b3bd", marginTop: 26, maxWidth: 600, lineHeight: 1.3 }}>
Turn any GitHub profile into a World-Cup-style player card, rated out of 99.
<div style={{ display: "flex", fontSize: 33, color: "#a8b3bd", marginTop: 24, maxWidth: 600, lineHeight: 1.3 }}>
Turn any GitHub profile into a World-Cup-style player card, rated out of 99. Now with trophies to earn.
</div>
{shelf && <div style={{ display: "flex", marginTop: 30 }}>{shelf}</div>}
</div>
<div style={{ display: "flex", fontSize: 28, color: "#6e7681" }}>gitfut.com</div>
</div>

{/* the real card as the hero (Torvalds sample), same renderer as the embeds */}
<div style={{ display: "flex" }}>{cardTree(card, assets, CARD_W)}</div>

{/* url as a caption centered under the card, out of the trophy row's way */}
<div style={{ position: "absolute", right: 72, bottom: 24, width: CARD_W, display: "flex", justifyContent: "center", fontSize: 26, color: "#6e7681" }}>
gitfut.com
</div>
</div>
),
{ ...size, fonts: assets.fonts },
Expand Down
27 changes: 19 additions & 8 deletions app/u/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { deEmDash } from "@/lib/text";
import { recordScout } from "@/lib/analytics";
import { loadCardAssets, cardTree } from "@/lib/og/renderCard";
import { loadCardFonts } from "@/lib/og/card";
import { groupAwards } from "@/lib/awards";
import { loadTrophyStills, trophyShelf } from "@/lib/og/trophies";
import type { Card } from "@/lib/scoring/types";

export const runtime = "nodejs";
Expand Down Expand Up @@ -76,7 +78,9 @@ export default async function Image({ params }: { params: Promise<{ username: st

const card = { ...raw, country: pickFlag(null, raw.country) ?? "" }; // GitHub-derived flag only
const accent = card.founder?.accent ?? TIER_ACCENT[card.finish] ?? "#39d353";
const assets = await loadCardAssets(card, CARD_W);
const groups = groupAwards(card.awards);
const [assets, stills] = await Promise.all([loadCardAssets(card, CARD_W), loadTrophyStills(groups)]);
const shelf = groups.length ? trophyShelf({ groups, stills, tall: 92 }) : null;

return new ImageResponse(
(
Expand All @@ -97,21 +101,28 @@ export default async function Image({ params }: { params: Promise<{ username: st
{/* left: the actual FUT card (same renderer as /<user>.png) */}
<div style={{ display: "flex", marginRight: 64 }}>{cardTree(card, assets, CARD_W)}</div>

{/* right: identity + the "why" — the card itself carries rating/stats/flag */}
{/* right: identity, then the trophy cabinet (or the archetype line when
the profile hasn't earned any silverware yet) */}
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
<div style={{ display: "flex", color: "#39d353", fontSize: 24, fontWeight: 700, letterSpacing: 3 }}>GITHUB × WORLD CUP 26</div>
<div style={{ display: "flex", fontSize: 76, fontWeight: 700, marginTop: 14, lineHeight: 1 }}>{card.name}</div>
<div style={{ display: "flex", marginTop: 20, fontSize: 34, fontWeight: 700 }}>
<div style={{ display: "flex", fontSize: 72, fontWeight: 700, marginTop: 14, lineHeight: 1 }}>{card.name}</div>
<div style={{ display: "flex", marginTop: 18, fontSize: 32, fontWeight: 700 }}>
<span style={{ display: "flex", color: accent }}>
{card.overall} {card.finishLabel}
</span>
<span style={{ display: "flex", color: "#6e7681", margin: "0 14px" }}>·</span>
<span style={{ display: "flex", color: "#c9d1d9" }}>{card.archetype}</span>
</div>
<div style={{ display: "flex", fontSize: 30, color: "#a8b3bd", marginTop: 22, lineHeight: 1.3, maxWidth: 600 }}>
{deEmDash(card.archetypeBlurb)}.
</div>
<div style={{ display: "flex", fontSize: 26, color: "#6e7681", marginTop: 32 }}>gitfut.com/{card.login}</div>

{shelf ? (
<div style={{ display: "flex", marginTop: 36 }}>{shelf}</div>
) : (
<div style={{ display: "flex", fontSize: 30, color: "#a8b3bd", marginTop: 22, lineHeight: 1.3, maxWidth: 600 }}>
{deEmDash(card.archetypeBlurb)}.
</div>
)}

<div style={{ display: "flex", fontSize: 26, color: "#6e7681", marginTop: 34 }}>gitfut.com/{card.login}</div>
</div>
</div>
),
Expand Down
96 changes: 96 additions & 0 deletions components/AwardModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"use client";

import { useEffect, useRef, useState } from "react";
import type { AwardInstance, AwardKey } from "@/lib/scoring/types";
import { AWARD_META } from "@/lib/awards";
import TrophySprite, { AWARD_SPRITES } from "./TrophySprite";

// Trophy details dialog, shared by the profile shelf and duel corners. Shows
// every instance of the award the card holds — a 3× Ballon d'Or lists all
// three years with their reasons. Follows the HowItWorksModal conventions:
// dialog semantics, focus on open, Escape and backdrop close, entrance
// transition (instant under reduced motion via the global reset).
export default function AwardModal({
awardKey,
instances,
onClose,
}: {
awardKey: AwardKey;
instances: AwardInstance[];
onClose: () => void;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
const meta = AWARD_META[awardKey];
const art = AWARD_SPRITES[awardKey];

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
panelRef.current?.focus();
const t = setTimeout(() => setShown(true), 10);
return () => {
document.removeEventListener("keydown", onKey);
clearTimeout(t);
};
}, [onClose]);

return (
<div
onClick={onClose}
className="fixed inset-0 z-[60] flex items-center justify-center bg-bg-deep/80 p-[22px] backdrop-blur-[6px]"
style={{ opacity: shown ? 1 : 0, transition: "opacity .25s ease" }}
>
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="award-title"
onClick={(e) => e.stopPropagation()}
className="relative flex max-h-[85vh] w-[min(560px,100%)] flex-col items-center gap-4 overflow-auto rounded-[20px] border border-line bg-[linear-gradient(180deg,var(--color-surface-2),var(--color-panel))] p-6 shadow-[0_40px_120px_rgba(0,0,0,.6)] outline-none sm:flex-row sm:items-start sm:gap-6"
style={{
opacity: shown ? 1 : 0,
transform: shown ? "translateY(0) scale(1)" : "translateY(14px) scale(.985)",
transition: "opacity .4s ease, transform .45s cubic-bezier(.16,1,.3,1)",
}}
>
<button
onClick={onClose}
aria-label="Close"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-lg bg-white/5 text-[15px] text-ink-faint transition hover:bg-white/10 hover:text-ink"
>
</button>

<div className="relative flex flex-none flex-col items-center sm:sticky sm:top-0">
<TrophySprite sprite={art.sprite} size={140} className={`animate-float ${art.tint ?? ""}`} />
</div>

<div className="flex min-w-0 flex-1 flex-col gap-2 text-center sm:text-left">
<h3
id="award-title"
className="font-display text-2xl font-black uppercase leading-tight tracking-wide text-gold-hi"
>
{meta.title}
</h3>

<p className="mt-1 text-sm leading-relaxed text-ink-soft">{meta.description}</p>

<ul className="mt-3 flex flex-col gap-2 border-t border-white/5 pt-3">
{instances.map((a) => (
<li key={`${a.year ?? ""}${a.edition ?? "held"}`} className="text-left">
<span className="font-display mr-2 text-[12px] font-bold tracking-[.14em] text-gold">
{a.edition ?? a.year ?? "CURRENT FORM"}
</span>
<span className="text-[12.5px] leading-snug text-ink-dim">{a.reason}</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
}
Loading
Loading