From d2f92b67e8e8cbadfca1b8ec8433b1f601c8f7b3 Mon Sep 17 00:00:00 2001 From: i12bp8 Date: Tue, 28 Apr 2026 13:20:03 +0200 Subject: [PATCH] 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 --- cloud-plugins/src/plugins/github.ts | 346 ++++++++++++++++------------ 1 file changed, 199 insertions(+), 147 deletions(-) diff --git a/cloud-plugins/src/plugins/github.ts b/cloud-plugins/src/plugins/github.ts index eaab336..83e4ae4 100644 --- a/cloud-plugins/src/plugins/github.ts +++ b/cloud-plugins/src/plugins/github.ts @@ -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(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; +}