github plugin: redesign for 208x112 tag

- Add month-initial axis under the heatmap
- Add current-streak counter in caption (accent-highlighted)
- Replace stats/bio fight with a single icon stat row (star, followers, repos)
- Fix bullet/middle-dot rendering bug (U+00B7 -> U+2022, in font table)
- Allow display name at scale 2 on wide tags only; safer truncation
- Robust avatar URL ?s= append (handles avatars without query string)
- Tighter geometry tuned for 208x112; still scales to 296x128
This commit is contained in:
i12bp8
2026-04-28 13:20:03 +02:00
parent 0eae6de165
commit d2f92b67e8
+199 -147
View File
@@ -1,30 +1,31 @@
/*
* GitHub Profile plugin.
*
* Layout (designed for 208x112, scales up to 296x128):
* Layout, tuned for the 208x112 tag (the common case) and gracefully
* scaling up to 296x128:
*
* ┌────────────────────────────────────────┐
* │ ┌──────┐ Linus Torvalds <- display name (scale 1)
* │ │ │ @torvalds ★ 178K <- handle + stars (scale 1, accent star)
* │ │AVATAR
* │ │ Creator of Linux, │ <- bio (scale 1, word-wrapped,
* │ unrepentantly cranky, multiple lines, can extend
* │ │ │ mostly benevolent dictator below the avatar)
* │ ───────
* │
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ <- contribution heatmap
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ (red dots, hottest = black)
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │
* │ 1,234 contribs / yr │ <- caption
* └────────────────────────────────────────┘
* ┌──────────────────────────────────────────
* │ ┌──────┐ Linus Torvalds display name
* │ │ │ @torvalds │ handle
* │ │AVATAR│ Creator of Linux, bio (wraps under
* │ │ │ unrepentantly cranky... │ avatar if it must)
* │ └──────┘
* │ ★ 178K ◉ 234 ◆ 712 repos │ stat row (1 line)
* │ ──────────────────────────────────── hairline rule
* │ J F M A M J J A S O N D month axis
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ contribution heatmap
* │ 1.2K contribs · 42d streak │ caption + streak
* └──────────────────────────────────────────┘
*
* Palette (kept light + airy on purpose):
* Palette:
* - Black: typography, avatar frame, hottest-day cells (level 4).
* - Accent: the star glyph and most heatmap cells (levels 1-3).
* - White: paper / bg.
* - Accent: star glyph, streak flame, heatmap levels 1-3.
* - White: paper.
*
* No solid coloured banner, no boxed badges - the card reads as a
* tiny print-magazine page rather than a UI screenshot.
* Data sources:
* - api.github.com/users/:login (name, bio, avatar, counts)
* - api.github.com/users/:login/repos (sum stargazers across page 1)
* - github-contributions-api.jogruber.de (daily heatmap + totals)
*/
import { Canvas, Ink } from "../canvas";
@@ -112,34 +113,61 @@ function wrapText(s: string, maxChars: number, maxLines: number): string[] {
const lines: string[] = [];
let cur = "";
let i = 0;
for (; i < words.length; i++) {
for (; i < words.length && lines.length < maxLines; i++) {
const w = words[i];
const cand = cur ? cur + " " + w : w;
if (cand.length <= maxChars) {
cur = cand;
} else {
if (cur) lines.push(cur);
if (lines.length >= maxLines) { cur = ""; break; }
cur = w.length <= maxChars ? w : w.slice(0, maxChars);
if (lines.length >= maxLines) { i++; break; }
}
}
if (cur && lines.length < maxLines) { lines.push(cur); cur = ""; }
/* Anything that didn't fit gets reflected as an ellipsis on the
* last line, so the user can tell text was truncated. */
if (i < words.length || cur) {
if (lines.length === 0) return lines;
if ((i < words.length || cur) && lines.length > 0) {
const last = lines[lines.length - 1];
if (last.length <= maxChars - 3) lines[lines.length - 1] = last + "...";
else lines[lines.length - 1] = last.slice(0, maxChars - 3) + "...";
lines[lines.length - 1] = last.length <= maxChars - 3
? last + "..."
: last.slice(0, maxChars - 3) + "...";
}
return lines;
}
/** Trailing streak in days. If today has 0 contributions we still
* count whatever streak ended yesterday (mirrors github.com's rule:
* a streak isn't broken until the day is fully over). */
function currentStreak(contribs: ContribCell[]): number {
let i = contribs.length - 1;
if (i >= 0 && contribs[i].count === 0) i--;
let s = 0;
while (i >= 0 && contribs[i].count > 0) { s++; i--; }
return s;
}
/** Compute the column index at which each calendar month FIRST appears
* in the heatmap (column = floor(dayIndex / 7)). Returns 12 entries
* (Jan..Dec) with the leftmost column where that month is visible, or
* -1 if the month isn't in the visible window. */
function monthColumns(contribs: ContribCell[], cols: number, rows: number): number[] {
const out = new Array<number>(12).fill(-1);
const padN = Math.max(0, contribs.length - cols * rows);
for (let i = padN; i < contribs.length; i++) {
const d = new Date(contribs[i].date + "T00:00:00Z");
const m = d.getUTCMonth();
const col = Math.floor((i - padN) / rows);
if (out[m] === -1) out[m] = col;
}
return out;
}
const MONTH_INITIAL = ["J","F","M","A","M","J","J","A","S","O","N","D"];
export const githubPlugin: Plugin = {
manifest: {
id: "github",
name: "GitHub Profile",
description: "Avatar, bio and contribution heatmap card",
description: "Avatar, bio, stats and contribution heatmap card",
accent_modes: 1 | 2 | 4,
params: [
{ key: "username", label: "Username", type: "string", default: "torvalds" },
@@ -170,22 +198,29 @@ export const githubPlugin: Plugin = {
return c;
}
/* ── Geometry ─────────────────────────────────────────────
* Tuned for 208×112; scales up to 296×128 with a bigger
* avatar and roomier heatmap cells. */
/* ── Geometry ───────────────────────────────────────────────
* Tuned around 208x112; everything is computed from W/H so the
* 296x128 tag (and any future panel) gets a nicer, roomier
* version of the same layout for free. */
const isWide = W >= 256;
const margin = 4;
const margin = isWide ? 5 : 3;
const charW = 6; // FONT_5x7_W + gap
const lineH = 9; // FONT_5x7_H + gap
/* Avatar: dominate the upper-left. Bigger here = more typography
* room AND more pixels for the dithered face. */
const avatarS = isWide ? 64 : 60;
const avatarS = isWide ? 60 : 50;
const avX = margin;
const avY = margin;
/* ── Avatar ─────────────────────────────────────────────────
* 1-px frame around a Floyd-Steinberg-dithered face. Fetch at
* 2× to give the dither real detail to chew on. The ?s= query
* param is what GitHub uses for size hints; avatar_url already
* has a `?v=4` so we append with `&`. */
c.rect(avX, avY, avatarS, avatarS, blk);
const avatar = await fetchImageGray(`${user.avatar_url}&s=${avatarS * 2}`);
const avatarUrl = user.avatar_url.includes("?")
? `${user.avatar_url}&s=${avatarS * 2}`
: `${user.avatar_url}?s=${avatarS * 2}`;
const avatar = await fetchImageGray(avatarUrl);
if (avatar) {
blitGrayDither(c, avX + 1, avY + 1, avatarS - 2, avatarS - 2, avatar, blk);
} else {
@@ -196,118 +231,121 @@ export const githubPlugin: Plugin = {
ini, blk, 5);
}
/* ── Right column: name / handle+stars / bio ─────────────── */
const tX = avX + avatarS + 6;
/* ── Right column: name / handle / bio ──────────────────────
* The bio is allowed to wrap below the avatar baseline if it
* needs the room - the stat row sits BELOW the avatar so they
* don't compete for vertical space (the old layout did, and
* the bio almost always lost). */
const tX = avX + avatarS + (isWide ? 8 : 5);
const tW = W - tX - margin;
const maxChars = Math.max(1, Math.floor(tW / charW));
/* Heatmap budget computed up-front so we know where the bio is
* allowed to flow into. */
/* Vertical budget for the bottom block (stat row + heatmap +
* caption). We reserve this up-front so the bio knows where to
* stop wrapping. */
const cols = 53, rows = 7;
const captionH = lineH + 2;
const heatCellTarget = isWide ? 4 : 3;
const heatBudget = rows * heatCellTarget;
const heatBlockH = heatBudget + captionH + 4;
const bioMaxBot = H - heatBlockH - 2;
const heatCell = isWide ? 3 : 2;
const heatGap = (isWide && cell_fits(cols, heatCell, 1, W - margin * 2)) ? 1 : 0;
const heatH = rows * heatCell + (rows - 1) * heatGap;
const monthH = lineH; // single row of single-letter labels
const statH = lineH; // ★ N ◉ N ◆ N
const capH = lineH; // bottom caption (contribs · streak)
const bottomBlockH = statH + 2 + monthH + heatH + 2 + capH + 1;
const bottomTop = H - bottomBlockH - 2;
let ty = avY;
/* Display name first, in primary ink. Falls back to login
* (capitalised) if the user has no display name set. */
const displayName = user.name && user.name.trim()
? truncate(ascii(user.name).trim(), maxChars)
: truncate(user.login, maxChars);
if (displayName) {
c.drawText(tX, ty, displayName, blk, 1);
ty += lineH;
}
/* Display name. Scale 2 if the column is wide enough AND the
* name is short enough; otherwise scale 1. */
const nameRaw = user.name && user.name.trim()
? ascii(user.name).trim()
: user.login;
const wantBig = !isWide ? false : c.textSize(nameRaw, 2).w <= tW;
const nameScale = wantBig ? 2 : 1;
const nameMax = nameScale === 2
? Math.max(1, Math.floor(tW / (charW * 2)))
: maxChars;
c.drawText(tX, ty, truncate(nameRaw, nameMax), blk, nameScale);
ty += 7 * nameScale + 2;
/* Handle + stars on a single line. The ★ glyph is drawn in
* accent for a deliberate splash of colour right next to the
* stars number; everything else stays primary. */
{
const handle = "@" + ascii(user.login);
const sNum = `\u2605 ${compact(stars ?? 0)}`;
const hSize = c.textSize(handle, 1);
const sSize = c.textSize(sNum, 1);
const sep = " ";
const sepW = c.textSize(sep, 1).w;
/* If both pieces fit, draw them inline. Else drop the handle
* (the avatar already says who this is). */
if (hSize.w + sepW + sSize.w <= tW) {
c.drawText(tX, ty, handle, blk, 1);
/* Star glyph in accent, number text in primary - splits the
* `★ 178K` string into two drawText calls so the colour break
* is exactly at the glyph. */
const sx = tX + hSize.w + sepW;
c.drawText(sx, ty, "\u2605", acc, 1);
c.drawText(sx + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1);
} else {
c.drawText(tX, ty, "\u2605", acc, 1);
c.drawText(tX + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1);
}
ty += lineH + 2;
}
/* Handle line. */
c.drawText(tX, ty, truncate("@" + ascii(user.login), maxChars), blk, 1);
ty += lineH;
/* Followers / repos line(s) - drawn at the BOTTOM of the right
* column, left-aligned to the same `tX` as name / handle / bio.
* Reserve their vertical room first so the bio knows when to
* stop wrapping. The single-line form is preferred; if it
* doesn't fit the column width, fall back to two stacked lines. */
const statSingle = `${compact(user.followers)} followers \u00B7 ${compact(user.public_repos)} repos`;
const statLines: string[] = c.textSize(statSingle, 1).w <= tW
? [statSingle]
: [`${compact(user.followers)} followers`,
`${compact(user.public_repos)} repos`];
const statsBlockH = statLines.length * lineH;
/* Stats sit just above the heatmap caption / heatmap, butted up
* against the bottom of the right column. */
const statsTop = bioMaxBot - statsBlockH;
const bioBudget = statsTop - 2 - ty; // px the bio is allowed
/* Bio - word-wrapped into the budget left after the stats are
* accounted for. */
if (user.bio && bioBudget >= lineH) {
const maxLines = Math.max(0, Math.floor(bioBudget / lineH));
/* Bio - allowed to flow under the avatar (down to bottomTop). */
if (user.bio) {
const bioBudget = bottomTop - ty;
const maxLines = Math.max(0, Math.floor(bioBudget / lineH));
if (maxLines > 0) {
const lines = wrapText(ascii(user.bio).trim(), maxChars, maxLines);
for (const line of lines) {
for (const line of wrapText(ascii(user.bio).trim(), maxChars, maxLines)) {
c.drawText(tX, ty, line, blk, 1);
ty += lineH;
}
}
}
/* Now drop the stats in left-aligned with everything else. */
/* ── Stat row ───────────────────────────────────────────────
* One horizontal line of icon+number triplets, full-width below
* the avatar. Star glyph in accent for a deliberate splash. */
const statY = bottomTop;
{
let sy = statsTop;
for (const line of statLines) {
c.drawText(tX, sy, line, blk, 1);
sy += lineH;
const items: Array<{ glyph: string; value: string; accentGlyph: boolean }> = [
{ glyph: "\u2605", value: compact(stars ?? 0), accentGlyph: true }, // ★
{ glyph: "\u25C9", value: compact(user.followers), accentGlyph: false }, // ◉
{ glyph: "\u25C6", value: compact(user.public_repos), accentGlyph: false }, // ◆
];
// Measure total width with " " between glyph & value, " " between items.
const sepW = c.textSize(" ", 1).w;
let totalW = 0;
const partW: number[] = [];
for (const it of items) {
const w = c.textSize(it.glyph + " " + it.value, 1).w;
partW.push(w);
totalW += w;
}
totalW += sepW * (items.length - 1);
let sx = margin + Math.max(0, ((W - margin * 2) - totalW) >> 1);
for (let i = 0; i < items.length; i++) {
const it = items[i];
c.drawText(sx, statY, it.glyph, it.accentGlyph ? acc : blk, 1);
const gW = c.textSize(it.glyph + " ", 1).w;
c.drawText(sx + gW, statY, it.value, blk, 1);
sx += partW[i] + sepW;
}
}
/* ── Heatmap ─────────────────────────────────────────────── */
const heatTop = H - heatBlockH;
const heatBot = H - captionH;
const heatH = heatBot - heatTop;
let cell = Math.max(1, Math.min(
Math.floor((W - margin * 2 + 1) / cols),
Math.floor((heatH + 1) / rows),
));
let gap = cell >= 3 ? 1 : 0;
while (cell > 1 &&
(cell * cols + gap * (cols - 1) > W - margin * 2 ||
cell * rows + gap * (rows - 1) > heatH)) {
if (gap > 0) gap--;
else cell--;
}
const gw = cell * cols + gap * (cols - 1);
const gh = cell * rows + gap * (rows - 1);
/* ── Heatmap geometry ───────────────────────────────────────
* Centred horizontally inside the page margins. */
const gw = cols * heatCell + (cols - 1) * heatGap;
const gh = rows * heatCell + (rows - 1) * heatGap;
const gx = (W - gw) >> 1;
const gy = heatTop + ((heatH - gh) >> 1);
const monthY = statY + statH + 2;
const gy = monthY + monthH;
/* ── Month axis ─────────────────────────────────────────────
* Single-letter initials placed at the left edge of each
* month's first visible column. Letters are kept at scale 1
* (5px wide) so even adjacent months don't collide unless the
* month is shorter than 5 columns (rare; only happens at the
* very start of the rolling window). */
if (contrib) {
const mc = monthColumns(contrib.contributions, cols, rows);
let lastX = -999;
for (let m = 0; m < 12; m++) {
const col = mc[m];
if (col < 0) continue;
const lx = gx + col * (heatCell + heatGap);
if (lx - lastX < 6) continue; // no overlap with prev label
c.drawText(lx, monthY, MONTH_INITIAL[m], blk, 1);
lastX = lx;
}
}
/* ── Heatmap cells ──────────────────────────────────────────
* Level 0 = paper (skip). Level 4 = solid black. Levels 1-3 in
* accent ink, getting denser with level so even on a mono tag
* you can still read the gradient (level 1 = centre dot,
* level 2 = inset square, level 3 = full cell). */
if (contrib) {
const data = contrib.contributions;
const padN = Math.max(0, data.length - cols * rows);
@@ -315,20 +353,19 @@ export const githubPlugin: Plugin = {
const off = i - padN;
const col = Math.floor(off / rows);
const row = off % rows;
const cx = gx + col * (cell + gap);
const cy = gy + row * (cell + gap);
const cx = gx + col * (heatCell + heatGap);
const cy = gy + row * (heatCell + heatGap);
const lvl = data[i].level;
if (lvl === 0) continue;
/* See doc-comment at top of file for the palette story. */
if (lvl >= 4) {
c.fillRect(cx, cy, cell, cell, blk);
} else if (lvl >= 3 || cell <= 2) {
c.fillRect(cx, cy, cell, cell, acc);
c.fillRect(cx, cy, heatCell, heatCell, blk);
} else if (lvl >= 3 || heatCell <= 2) {
c.fillRect(cx, cy, heatCell, heatCell, acc);
} else if (lvl === 2) {
c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
c.fillRect(cx + 1, cy + 1, heatCell - 2, heatCell - 2, acc);
} else {
if (cell >= 4) c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
else c.setPixel(cx + (cell >> 1), cy + (cell >> 1), acc);
if (heatCell >= 4) c.fillRect(cx + 1, cy + 1, heatCell - 2, heatCell - 2, acc);
else c.setPixel(cx + (heatCell >> 1), cy + (heatCell >> 1), acc);
}
}
} else {
@@ -337,27 +374,42 @@ export const githubPlugin: Plugin = {
c.drawText((W - sz.w) >> 1, gy + ((gh - sz.h) >> 1), t, blk, 1);
}
/* ── Caption: tight, abbreviated so it always fits ─────────
* "1.2K contribs / yr" is 17 chars ≈ 102 px at scale 1, fits in
* 200 px easily. We use compact() on the total so a 50K-streak
* pro doesn't break the layout. */
/* ── Caption + streak ───────────────────────────────────────
* "1.2K contribs • 42d streak" - bullet glyph (U+2022) is in
* the extra-glyph table so it actually renders (the previous
* version used U+00B7 which is not, leaving a phantom gap). */
{
const total = contrib
? (contrib.total["lastYear"]
?? Object.values(contrib.total).reduce((a, b) => a + b, 0))
: 0;
const cap = contrib
? `${compact(total)} contribs / yr`
: `last 12 mo \u00B7 offline`;
const streak = contrib ? currentStreak(contrib.contributions) : 0;
const left = contrib ? `${compact(total)} contribs` : `last 12 mo offline`;
const right = contrib && streak > 0 ? `${streak}d streak` : "";
const sep = " \u2022 ";
const cap = right ? left + sep + right : left;
const cs = c.textSize(cap, 1);
const cx = (W - cs.w) >> 1;
const cy = H - cs.h - 2;
c.drawText(cx, cy, cap, blk, 1);
const cyText = gy + gh + 2;
const cxText = (W - cs.w) >> 1;
c.drawText(cxText, cyText, cap, blk, 1);
/* Highlight the streak number in accent - tiny, deliberate
* splash that mirrors the star glyph above. */
if (right) {
const leftW = c.textSize(left + sep, 1).w;
const numW = c.textSize(`${streak}`, 1).w;
c.drawText(cxText + leftW, cyText, `${streak}`, acc, 1);
c.drawText(cxText + leftW + numW, cyText, "d streak", blk, 1);
}
}
/* Outer 1-px frame. */
c.rect(0, 0, W, H, blk);
return c;
},
};
/** Helper used while picking heatmap geometry: does N cells of
* size `cell` plus (N-1) gaps fit in `avail` pixels? */
function cell_fits(n: number, cell: number, gap: number, avail: number): boolean {
return n * cell + (n - 1) * gap <= avail;
}