A GitHub README can show exactly one kind of dynamic content: an image. I wanted that image to be a whole LeetCode dashboard, with a profile, a solved ring, streaks, skill chips, language chips and a year of submissions, that I could shrink to any combination of those parts, restyle in 167 themes, and have it animate on a page where no JavaScript runs. This post is how that card works: one serverless function that returns one SVG, the layout engine behind its six sections, a streak calculation I wrote because LeetCode's own number only sees a year, and the trick that keeps colour animations in lockstep behind GitHub's image proxy.
One view, from README to pixels#
The single most important request is the plain one: someone opens a profile page that embeds the card. GitHub does not let the browser fetch arbitrary image URLs; it rewrites them to its camo proxy, which fetches the image server-side and serves it from GitHub's own domain. So the first thing to understand is that the card has to be a self-contained file. Everything it needs, the avatar included, travels inside the SVG.
Understand the problem#
I started from what a README imposes. The reader never visits my site, cannot click through an image, and sees whatever bytes GitHub's proxy has cached. Within that I wanted a card that a visitor would read in two seconds and I could customise in two minutes.
Functional requirements#
- Show a LeetCode profile as a single image: avatar, username, global rank.
- Show solved totals and the Easy, Medium and Hard split as a ring and three bars.
- Show total submissions, the current streak and the highest streak, computed from the whole history.
- Show the top problem tags and the languages used, as chips with counts.
- Show a submission heatmap of the last 52 weeks.
- Let the owner choose any combination of the six sections and have the card resize to fit.
- Offer 167 themes plus per-colour overrides, gradient backgrounds, radius, transparency and short numbers.
- Animate on load, and provide an opt-out.
- Provide a live configurator that outputs the exact embed snippet.
Non-functional requirements#
- Works as an
img. No script, no external fonts or images, and no dependency on hover. - Never a broken image. Every failure, including LeetCode blocking the server, must still return a valid SVG.
- Cheap to run. Responses are cached so most views never reach LeetCode, which is unauthenticated and not an official public API.
- Safe by construction. Every query parameter is validated and clamped, and the server only ever serves one configured account.
- Readable. Light and dark variants of the animated theme have to meet contrast requirements.
- Deterministic output. The same inputs give the same SVG, so the renderer can be tested with plain string assertions. The repository has 288 vitest tests.
README img --> camo --> /api/card --> LeetCode GraphQL
|
'--> SVG string back through camo
Contract: GET with query params, 200 + image/svg+xml, always
No script, no external requests, one fileThe set-up#
Core entities#
- CardParams. The validated query: sections, theme, colour overrides, radius, flags, chip limits, and whether to return SVG or JSON.
- RawLeetCodeData. What LeetCode says: profile, solved counts, tags, languages, the merged submission calendar and the avatar as a data URI.
- StreakResult. Derived: current and highest streaks with their date ranges, active days and the first active day.
- ThemeColors. Eleven named colour slots that every section reads from, so a theme is just a record.
- Section and Placement. A section name plus an x, y and width the layout assigned to it.
The interface#
GET /api/card all six sections, default theme
GET /api/card?sections=profile,solved,streak
GET /api/card?theme=tokyonight&hide_border=true&border_radius=12
GET /api/card?theme=sunset-gradient§ions=profile,solved,streak,languages
GET /api/card?theme=dynamic cycling palette (dynamic_light for light pages)
GET /api/card?background=35,4158d0,c850c0,ffcc70 gradient: degrees, then hex stops
GET /api/card?type=json the computed data instead of an image
GET / the configurator pageParameters are forgiving by design. Sections are deduplicated and always drawn in the canonical order profile, solved, streak, skills, languages, heatmap, whatever order the URL lists them in. top_skills and top_langs are clamped to 1 to 12, the radius to 0 to 60, and only the exact string true turns a flag on. A bad theme name falls back to default and a bad colour is ignored, so a typo in a README never produces an error picture.
The data model#
Build it one requirement at a time#
1. Show the card, in any combination of sections#
The first board is the spine: parse, fetch, lay out, render. The renderers are the only part that knows how to draw. The layout engine sits between parsing and drawing and decides the size of the whole card.
- The query is parsed into a typed value that cannot fail.
- The section list is sorted into the canonical order and handed to
layout(), which returns the card's width and height, a box per section and the divider lines. - Separately, the LeetCode client returns raw data and the streak function derives the rest.
- Each placement is looked up in a table of section renderers, each a pure function of theme, data, box and a few flags.
2. Themes, colours and chrome#
A theme is eleven colours. The 167 are ported from the DenverCoder1 streak-stats catalogue by a script, so the catalogue regenerates from upstream. On top of the named theme, any slot can be overridden by name, and background additionally accepts a gradient written as degrees followed by hex stops. Four-digit and eight-digit hex values are split into a six-digit colour plus an opacity at generation time, with a final pass over the finished SVG as a safety net, because some renderers do not understand alpha hex.
- Parsed overrides are validated against hex, gradient and the 148 CSS colour names, and everything else is dropped.
- The theme resolver merges named theme, then overrides, then applies
hide_borderandtransparent, so the flags win over any theme including gradient ones.
3. Load animations that survive an image tag#
Every section writes its own small <style> block with keyframes whose names carry a per-render id, so two cards on one page cannot collide. The solved ring sweeps by animating stroke-dashoffset, the bars animate scaleX, the streak number pops, and the streak ring has a looping glow. All of it sits inside @media (prefers-reduced-motion: no-preference), so viewers who ask for less motion get a still card.
- The palette store feeds a keyframe generator that writes a 150 second cycle into the SVG.
- The generated CSS classes are attached to the strokes and text that should change colour.
disable_animations=true works twice. Sections skip emitting animation markup, and a post-processing pass on the final SVG removes <style> blocks and neutralises leftover opacity:0, animation and transform-origin declarations, so a card can never get stuck mid-fade.
4. Cache it, and never return a broken image#
The handler picks headers by theme. Static themes get public, max-age=60, s-maxage=120, stale-while-revalidate=86400. The dynamic theme gets max-age=0, no-cache, no-store, must-revalidate. Errors, including a missing username and LeetCode blocking the request, return HTTP 200 with a small sad-face card and s-maxage=300.
- A successful render gets headers chosen from the theme name.
- Any failure becomes a RenderableError, which renders as the error card with its own, longer shared cache so a block from LeetCode does not turn into a stampede.
5. A live configurator#
The page at / is a control panel for the same endpoint: a theme picker, six section toggles, flags, radius and chip sliders, per-colour overrides, and a copy button for the markdown. Its preview is the real server SVG, not a mock, and it falls back to labelled sample data only if the API cannot be reached. Because the preview SVG is inlined into the page rather than loaded as an image, it is also where hover tooltips on heatmap cells work.
- Every control changes a query string; the page requests the card with it.
- The embed snippet shows the same query as markdown and as a raw URL.
Deep dives#
Deep dive 1: animating inside an image proxy#
Drive the colour from a script
A script inside the SVG is the first idea, and it does not run: an SVG used as an image is a static document with scripting disabled. The same rule blocks hover handlers and external fonts or images, which is why the heatmap tooltips are SVG title elements that only show when the file is opened directly, and why the avatar is embedded as a data URI of at most 60 KB.
CSS keyframes that cycle colours
CSS animation does run inside an image. A keyframe rule that changes fill over time needs no script. The problem is the start: every animation begins when its image is first painted, so two cards fetched seconds apart, or a card repainted after a cache hit, show different colours. In the history this shows up as a run of fixes in two days: a daily rotation, a ten minute rotation, a fixed master timeline, removal of delay drift. Two attempts at a per-visitor starting palette from the current minute were each reverted.
One shared clock, entered at render time
Define one master timeline, the same in every card: 30 palettes, a 150 second loop, each palette held for 82 percent of its slot. Then start the animation partway through, at the point the wall clock is at the moment the card is rendered, using a negative animation-delay. Two cards rendered in the same moment are in the same place in the loop, whatever order they arrive in, and a card that spent four seconds fetching data starts four seconds further along and cancels its lateness. Colour is then a function of server time, with no shared state at all.
Deep dive 2: streaks from the whole history#
Trust the number LeetCode returns
The profile query returns a streak field, and the card keeps it as reportedStreak. But the default calendar only covers about the last twelve months, so a longer run is cut off, and the query offers no highest streak at all.
Walk the trailing year yourself
Computing from the default calendar fixes the current streak and still caps the highest streak at what fits in a year.
Merge every year, then walk every day
The query lists activeYears. The client issues one small query per year in parallel, converts each timestamp to a UTC epoch day and merges the results into one calendar. A year that fails to load is dropped rather than failing the card. Then one pass from the first active day to today computes both streaks. An active day extends the run. An inactive day resets it, except today, which gets grace until it ends. Tomorrow is included only if it already has activity, which covers a viewer whose timezone is ahead of UTC.
Deep dive 3: a layout engine without a solver#
Fixed positions for every section
If every section had one position, removing one would leave a hole, and the card would stay 840 pixels wide with a gap in the middle.
A constraint solver
A flexbox-style solver would cope with any combination, but this is a handful of rectangles on a fixed grid, and a solver adds a dependency and a lot of ways to be wrong in the corners that matter, such as the three-column top row.
An explicit table for designed layouts, row stacking for the rest
The designs worth drawing by hand are written out as numbers: profile alone, solved alone, streak alone, profile with solved, the three-section top row, the heatmap alone, and a single chip section. Everything else goes to a generic layout that stacks three bands: the top row of profile, solved and streak; then the chip bands; then the heatmap. The generic layout takes hints from the data, because a skills band whose chips wrap to a second row needs 104 pixels, not 78.
Deep dive 4: caching, failure and a single-user lock#
Look up any user on request
A ?user= parameter is the obvious feature and the obvious risk: anyone could script your deployment into hammering LeetCode for strangers, and you would own a public proxy.
Cache everything, allow lookups
Caching by user name reduces load, but the key space is unbounded and the cache can still be used to probe.
One configured user, cached at the edge
The deployment serves only the account named in LEETCODE_USERNAME; any user= parameter is ignored. The key space is the set of query strings for one account, which is tiny, so the edge cache absorbs nearly every view. No database is involved.
The final design#
- GitHub's proxy requests
/api/card?...for a README that embeds it. - The handler parses the query into a typed CardParams with defaults and clamps.
- The LeetCode client sends the combined query, then one query per active year and the avatar download, in parallel.
- The streak function walks the merged calendar from the first active day.
- The layout engine returns the size, the boxes and the dividers, using chip row counts from the data.
- The theme resolves, overrides apply, and the six renderers and the card chrome produce one SVG string.
- The handler picks Cache-Control by theme and sends the image; any failure along the way becomes the error card instead.
What I would change#
- Test the layout against real data shapes. The chip-row estimate is a copy of a width heuristic, and a test that renders every section combination with long and short chip names would catch drift sooner.
- Keep one source of truth for cache timings. The README describes multi-hour caching while the handler sets 120 seconds; the handler is right and the documentation lags it.
- Render the dynamic theme once per time slot at the edge. Rendering on every view is what makes the palette sync work, but a short edge cache aligned to a slot would cut load without losing the sync.
- Move the animation timing into data. Fade delays are hard-coded per section. A single timeline table would make the order of the load sequence easier to adjust.
- Add a visual regression pass. The unit tests check strings. A screenshot diff of the gallery would catch a theme that stops being readable.
Test yourself#
Why are the heatmap tooltips missing when the card is embedded in a README?
- GitHub strips SVG title elements for security.
- An SVG shown through an img tag is a static document: it runs CSS animation but not scripts or hover interaction.
- The server omits them to save bytes.
- Tooltips need a database.
Several dynamic cards load at slightly different moments. What keeps their colours in step?
- A WebSocket that broadcasts the current colour.
- A cookie holding the last palette.
- Every card starts the same 150 second loop at the wall-clock position at render time, using a negative animation delay.
- The browser caches the first card and reuses it.
Why does the dynamic theme send max-age=0 and no-store?
- A cached copy would freeze the render-time offset, so the proxy must refetch to keep the colour in sync.
- To make the SVG smaller.
- Because dynamic themes need a database lookup.
- To disable the load animations.
A viewer has not submitted yet today but did submit on each of the previous four days. What is the current streak?
- 0, because today is empty.
- 1.
- 5, counting today.
- 4, because an empty today is forgiven until it ends.
Why does the card fetch one extra query per active year?
- To increase the rank shown.
- LeetCode's default calendar covers only about the last twelve months, so older history, and therefore the true highest streak, needs one query per year.
- To warm LeetCode's cache.
- To fetch the avatar.
Why does a failed render return HTTP 200 with a card that says what went wrong?
- HTTP 200 makes LeetCode retry.
- Error codes are not allowed on SVG responses.
- A 4xx or 5xx on an image URL shows a broken image in the README, while a 200 with a drawn message degrades gracefully and can be cached to protect the upstream.
- The error card is faster to draw.