Two Things I Learned Painting Pixel Art onto GitHub's Contribution Graph

I had a dumb idea on a Sunday: could I write my own name on my GitHub contribution graph? Not the boring all-green pattern that looks like someone clocking in for daily attendance, but something that actually looked like a thing — text with shading, a gradient background, the works.
The graph turns out to be a 53×7 grid: 371 pixels at exactly 5 grey levels (empty + 4 greens). That's a delightful design constraint. Small enough to fit anywhere, large enough to render real letters, and the 5-level palette forces you to think about dithering instead of color choices.
Existing tools (gitfiti, github-spray, github_painter, etc.) have been around since 2014, but they all top out at binary "fill or don't fill" pixels. None treats the canvas as a real greyscale image with halos, gradients, photographic shading.
Well, I'm a vibe coder and Claude Code sits right next to me. We're in an era where grinding away with it for half a Sunday afternoon is enough to spit out a halfway decent tool. So I just built it. github-commit-painter lets you design at full 256 levels, dither (Floyd-Steinberg or Atkinson) down to GitHub's 5 shades, and emit fake-dated commits at the right intensity per cell.
The dithering pipeline isn't actually the interesting part. The interesting part is the two surprisingly weird things I learned about GitHub's contribution-graph algorithm along the way.
1. The 5 colors are quartile-relative, not absolute
I assumed the legend at the bottom of the graph (Less ▢▢▢▢▢ More) corresponded to fixed commit thresholds. Like, 1–3 commits = light green, 10+ = dark green. That's wrong.
GitHub computes shading per user, per year, in three steps:
- Take all your active days (commits > 0) in the date range.
- Rank them by commit count.
- Top 25% → L4 (darkest), next 25% → L3, and so on.
So "L4" means "top quartile of your active days." If your normal max is 153 commits/day, painting cells at exactly 156 commits each will rank them right at the L4 threshold — and you'll see a muddy mix of L2/L3 instead of solid dark green.
This is why my first attempt looked weak even though I had thousands of commits per day. The fix: query the user's trailing-365-day max-day commits via the GraphQL viewer.contributionsCollection.contributionCalendar endpoint, then pick a multiplier m such that 4 × m > user_max + safety_margin. For my account that came out to 39× — each "L4" cell on my design needs 156 actual commits to render as the darkest green.
For "HULRYUNG" with a halo and a gradient background, that landed at 35,022 commits for one year. About 60 seconds to generate, since they're empty commits with a backdated GIT_AUTHOR_DATE.
2. gh auth token doesn't have the user:email scope
The painter does a verified-email preflight before generating commits — if your git config user.email doesn't match a verified GitHub email, none of those commits will count, and you'll have wasted a lot of work.
The natural way to fetch verified emails is the REST endpoint /user/emails. With a default gh auth token it returns 404. OK, switch to GraphQL viewer.email. That returns 403 with:
INSUFFICIENT_SCOPES: The 'email' field requires one of the following
scopes: ['user:email', 'read:user'], but your token has only been
granted the: ['gist', 'read:org', 'repo', 'workflow'] scopes.
So under the default gh scope set you genuinely cannot list a user's verified emails. You can tell the user to run gh auth refresh -s user:email, but that's friction at exactly the moment they want to try the tool.
The workaround I shipped: synthesize the two users.noreply.github.com aliases. Both viewer.login and viewer.databaseId work under base scope, so:
data = self._post("query { viewer { login databaseId } }")
login = data["viewer"]["login"]
db_id = data["viewer"]["databaseId"]
return [
VerifiedEmail(f"{login}@users.noreply.github.com", primary=False),
VerifiedEmail(f"{db_id}+{login}@users.noreply.github.com", primary=False),
]If the user's git config user.email is one of those two forms (which most privacy-conscious devs already use), preflight passes without any scope upgrade. If it's a different verified email, they get a clear actionable message instead of a silent "you painted 35,022 commits and nothing showed up."
The result
The final tool composes layers (background → halo → text) at 256-level greyscale, then dithers at output time. Embedded 5×7 raster font for crisp small text, a pyfiglet adapter for decorative styles. ~1,800 lines of Python and ~125 tests.
Here's "HULRYUNG" painted on my 2018 graph. The whole thing is on GitHub at github.com/hulryung/github-commit-painter.
Looking back, the real fun of this project wasn't "I drew a picture on a contribution graph." It was discovering that even a tiny 53×7 piece of UI sits on top of a per-user quartile normalization, with a small piece of OAuth scope politics tucked in next to it. Getting to peel back one layer of that — on the back of a dumb Sunday-afternoon impulse, with Claude Code at my side for half an afternoon — was, by itself, more than enough to justify the 35,022 commits.