@shiinasaku/github-card 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/card.ts CHANGED
@@ -1,28 +1,17 @@
1
+ import { parse, formatHex, formatHex8 } from "culori";
1
2
  import {
2
3
  resolveColors,
3
4
  kFormat,
4
5
  escapeXml,
5
6
  icon,
6
7
  wrapText,
8
+ measureText,
7
9
  FONT_FAMILY,
8
10
  resolveTw,
9
11
  } from "./utils/index";
10
- import type { UserProfile, UserStats, LanguageStat } from "./types";
11
-
12
- export type CardOpts = {
13
- theme?: string;
14
- title_color?: string;
15
- text_color?: string;
16
- icon_color?: string;
17
- bg_color?: string;
18
- border_color?: string;
19
- hide_border?: boolean;
20
- compact?: boolean;
21
- hide?: string[];
22
- hide_langs?: string[];
23
- show_langs?: string[];
24
- animate?: boolean;
25
- };
12
+ import type { UserProfile, UserStats, LanguageStat, CardOptions } from "./types";
13
+
14
+ export type CardOpts = CardOptions;
26
15
 
27
16
  const STAT_DEFS = [
28
17
  { key: "stars", iconName: "star" as const, label: "Stars" },
@@ -38,15 +27,46 @@ type LangVisual = {
38
27
  size: number;
39
28
  };
40
29
 
30
+ /* ── tiny SVG builder ──────────────────────────────────────── */
31
+
32
+ function el(
33
+ tag: string,
34
+ attrs: Record<string, string | number | undefined>,
35
+ children = "",
36
+ ): string {
37
+ let a = "";
38
+ for (const [k, v] of Object.entries(attrs)) {
39
+ if (v === undefined || v === null || v === "") continue;
40
+ a += ` ${k}="${escapeXml(String(v))}"`;
41
+ }
42
+ if (!children) return `<${tag}${a} />`;
43
+ return `<${tag}${a}>${children}</${tag}>`;
44
+ }
45
+
41
46
  /* ── helpers ───────────────────────────────────────────────── */
42
47
 
43
48
  function raw(token: string): string {
44
- const m = resolveTw(token, "fill").match(/fill="([^"]+)"/);
45
- return m?.[1] ?? token;
49
+ const value = resolveTw(token, "fill").match(/fill="([^"]+)"/)?.[1] ?? token;
50
+ const color = parse(value);
51
+ return color
52
+ ? color.alpha === undefined || color.alpha === 1
53
+ ? formatHex(color)
54
+ : formatHex8(color)
55
+ : "#6b7280";
46
56
  }
47
57
 
48
- function clamp(s: string, n: number): string {
49
- return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "\u2026";
58
+ function fitText(text: string, width: number, size: number, weight = 400): string {
59
+ if (measureText(text, size, weight) <= width) return text;
60
+ const chars = Array.from(text);
61
+ while (chars.length && measureText(chars.join("") + "…", size, weight) > width) chars.pop();
62
+ return chars.join("").trimEnd() + "…";
63
+ }
64
+
65
+ // Stable output keeps ETags useful; the gallery supplies a distinct prefix per theme.
66
+ function cardId(value: string): string {
67
+ let hash = 2166136261;
68
+ for (const char of value) hash = Math.imul(hash ^ char.codePointAt(0)!, 16777619);
69
+ return `card-${(hash >>> 0).toString(36)}`;
50
70
  }
51
71
 
52
72
  function buildLanguageSegments(
@@ -64,7 +84,6 @@ function buildLanguageSegments(
64
84
  tinySize += lang.size;
65
85
  continue;
66
86
  }
67
-
68
87
  visible.push(lang);
69
88
  }
70
89
 
@@ -72,7 +91,7 @@ function buildLanguageSegments(
72
91
  visible.push({ name: "Other", color: "#6b7280", size: tinySize });
73
92
  }
74
93
 
75
- return visible.length > 0 ? visible : langs;
94
+ return visible;
76
95
  }
77
96
 
78
97
  /* ── renderer ──────────────────────────────────────────────── */
@@ -83,7 +102,9 @@ export function renderCard(
83
102
  langs: LanguageStat[],
84
103
  opts: CardOpts = {},
85
104
  ): string {
86
- const hidden = new Set((opts.hide ?? []).map((k) => k.toLowerCase()));
105
+ const namespace = cardId(JSON.stringify([user, stats, langs, opts]));
106
+ const selector = `#${namespace}-root`;
107
+ const hidden = new Set((opts.hide ?? []).map((k) => k.trim().toLowerCase()));
87
108
  const c = resolveColors(opts);
88
109
  const compact = opts.compact ?? false;
89
110
  const animate = opts.animate ?? false;
@@ -97,531 +118,432 @@ export function renderCard(
97
118
  const iconColor = raw(c.icon);
98
119
  const borderColor = raw(c.border);
99
120
 
100
- /* ── font (XML-safe) ──────────────────────────────────────── */
101
- const fontFamily = FONT_FAMILY.replace(/"/g, "&apos;");
102
-
103
- /* ── profile text setup ───────────────────────────────────── */
104
- const displayName = user.name || user.login;
105
- const nameEsc = escapeXml(clamp(displayName, 28));
106
- const loginEsc = escapeXml(clamp(user.login, 24));
107
- const pronouns = !compact && user.pronouns ? escapeXml(clamp(user.pronouns, 16)) : "";
121
+ /* ── profile text ─────────────────────────────────────────── */
122
+ const displayName = user.name?.trim() || user.login;
123
+ const pronouns = !compact && user.pronouns ? user.pronouns : "";
108
124
  const bioRaw = !compact && user.bio ? user.bio : "";
109
- const bioLines = bioRaw ? wrapText(bioRaw, 44, 2) : [];
110
- const twitter = !compact && user.twitter ? escapeXml(clamp(user.twitter, 22)) : "";
111
- const avatar = (user.avatarUrl || "").replace(/&/g, "&amp;");
125
+ const twitterRaw = !compact && user.twitter ? user.twitter.replace(/^@/, "") : "";
126
+ const avatar = user.avatarUrl || "";
127
+ const metaRaw = `@${user.login}${pronouns ? ` · ${pronouns}` : ""}`;
112
128
 
113
129
  const visible = STAT_DEFS.filter((d) => !hidden.has(d.key));
114
130
  const activeLangsList = langs.filter((l) => {
131
+ if (!Number.isFinite(l.size) || l.size <= 0) return false;
115
132
  const name = l.name.toLowerCase().trim();
116
133
  if (showLangs.size > 0 && !showLangs.has(name)) return false;
117
134
  if (hideLangs.has(name)) return false;
118
135
  return true;
119
136
  });
120
137
 
121
- /* ── dimensions (dynamic width) ───────────────────────────── */
122
- const PX = 25; // horizontal padding
123
- const PY = 25; // top padding
124
- const avatarSize = 64;
138
+ /* ── dimensions (measured, dynamic width) ───────────────── */
139
+ const PX = 24; // horizontal padding
140
+ const PY = 22; // top padding
141
+ const avatarSize = 60;
125
142
  const avatarR = avatarSize / 2;
143
+ const textGap = 16;
126
144
 
127
- // Estimate text width
128
- const metaLen = user.login.length + (pronouns ? pronouns.length + 3 : 0);
129
- const maxBioLen = bioLines.length ? Math.max(...bioLines.map((l) => l.length)) : 0;
130
- const profileTextW = Math.max(
131
- displayName.length * 11, // ~11px per bold char
132
- metaLen * 7.5, // ~7.5px per medium char
133
- maxBioLen * 6.5, // ~6.5px per small char
134
- twitter.length * 7 + 20,
135
- );
136
-
137
- const profileW = PX + avatarSize + 16 + profileTextW + PX;
138
- const statsW = visible.length > 0 ? visible.length * 85 + PX * 2 : 0;
139
- const langsW = activeLangsList.length > 0 ? 300 : 0; // sensible minimum for languages
140
-
141
- const W = Math.round(Math.min(540, Math.max(340, profileW, statsW, langsW)));
145
+ const nameW = measureText(displayName, 17, 700);
146
+ const metaW = measureText(metaRaw, 12.5, 500);
147
+ const profileW = PX + avatarSize + textGap + Math.max(nameW, metaW) + PX;
148
+ const statsW = visible.length > 0 ? visible.length * 84 + PX * 2 : 0;
149
+ const W = Math.round(Math.min(540, Math.max(340, profileW, statsW)));
142
150
  const contentW = W - PX * 2;
151
+ const profileTextW = contentW - avatarSize - textGap;
152
+ const nameEsc = escapeXml(fitText(displayName, profileTextW, 17, 700));
153
+ const metaStr = escapeXml(fitText(metaRaw, profileTextW, 12.5, 500));
154
+ const bioLines = wrapText(bioRaw, 44, 2).map((line) => fitText(line, profileTextW, 11.5));
155
+ const twitter = escapeXml(
156
+ fitText(twitterRaw ? `@${twitterRaw}` : "", profileTextW - 15, 11.5, 500),
157
+ );
143
158
 
144
- /* ── profile section ──────────────────────────────────────── */
145
- const infoX = PX + avatarSize + 14;
146
- const nameY = PY + 22;
147
- const loginY = nameY + 18;
148
- const bioStartY = loginY + 16;
159
+ /* ── profile section ─────────────────────────────────────── */
160
+ const infoX = PX + avatarSize + textGap;
161
+ const nameY = PY + 20;
162
+ const loginY = nameY + 17;
163
+ const bioStartY = loginY + 15;
149
164
  const twitterY = bioStartY + bioLines.length * 14 + (bioLines.length ? 4 : 0);
150
165
 
151
- let profileH = loginY + 10 - PY;
166
+ let profileH = loginY + 8 - PY;
152
167
  if (bioLines.length) profileH = bioStartY + bioLines.length * 14 - PY;
153
168
  if (twitter) profileH = twitterY + 10 - PY;
154
- profileH = Math.max(profileH, avatarSize + 4);
169
+ profileH = Math.max(profileH, avatarSize + 6);
155
170
 
156
171
  /* ── stats section ────────────────────────────────────────── */
157
- const statsY = PY + profileH + 14;
172
+ const statsY = PY + profileH + 16;
158
173
  const statW = visible.length > 0 ? contentW / visible.length : 0;
159
- const statsH = visible.length > 0 ? 50 : 0;
174
+ const statsH = visible.length > 0 ? 58 : 0;
160
175
 
161
- /* ── languages section ────────────────────────────────────── */
176
+ /* ── languages section ─────────────────────────────────── */
162
177
  const totalSize = activeLangsList.reduce((s, l) => s + l.size, 0) || 1;
163
178
  const sorted = [...activeLangsList].sort((a, b) => b.size - a.size);
164
- const visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
165
- const barY = statsY + statsH + (statsH ? 16 : 12);
179
+ let visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
180
+ // The legend and bar must describe the same groups and colors.
181
+ if (!compact && visualLangs.length > 6) {
182
+ const named = visualLangs.filter((lang) => lang.name !== "Other").slice(0, 5);
183
+ const otherSize = totalSize - named.reduce((sum, lang) => sum + lang.size, 0);
184
+ visualLangs = [...named, { name: "Other", color: "#6b7280", size: otherSize }];
185
+ }
186
+ const barY = statsY + statsH + (statsH ? 18 : 14);
166
187
  const barH = 10;
167
-
168
- const maxLegend = compact ? 0 : 6;
169
- const legendLangs = visualLangs.slice(0, maxLegend);
170
- const otherSize = visualLangs.slice(maxLegend).reduce((s, l) => s + l.size, 0);
171
-
172
- const legendY = barY + barH + 12;
173
- const legendRowH = 16;
174
- const legendCols = 3;
188
+ const legendY = barY + barH + 14;
189
+ const legendRowH = 17;
190
+ const legendCols = Math.min(3, Math.max(1, Math.floor(contentW / 150)));
175
191
  const legendColW = contentW / legendCols;
176
- const legendItems: { name: string; color: string; pct: string }[] = legendLangs.map((l) => ({
177
- name: clamp(l.name, 14),
178
- color: l.color,
179
- pct: ((l.size / totalSize) * 100).toFixed(1),
180
- }));
181
- if (otherSize > 0 && legendItems.length > 0) {
182
- const existingOther = legendItems.find((item) => item.name === "Other");
183
- if (existingOther) {
184
- existingOther.pct = (
185
- (((Number(existingOther.pct) / 100) * totalSize + otherSize) / totalSize) *
186
- 100
187
- ).toFixed(1);
188
- } else {
189
- legendItems.push({
190
- name: "Other",
191
- color: "#6b7280",
192
- pct: ((otherSize / totalSize) * 100).toFixed(1),
192
+ const legendItems = compact
193
+ ? []
194
+ : visualLangs.map((lang) => {
195
+ const pct = ((lang.size / totalSize) * 100).toFixed(1);
196
+ const suffixW = measureText(` ${pct}%`, 11, 500);
197
+ return {
198
+ name: fitText(lang.name, legendColW - 20 - suffixW, 11, 500),
199
+ color: raw(lang.color),
200
+ pct,
201
+ };
193
202
  });
194
- }
195
- }
196
203
  const legendRows = compact ? 0 : Math.ceil(legendItems.length / legendCols);
197
204
 
198
205
  const hasLangs = activeLangsList.length > 0;
199
- const langSectionH = hasLangs ? barH + 12 + legendRows * legendRowH + 8 : 0;
206
+ const langSectionH = hasLangs ? barH + 14 + legendRows * legendRowH + 10 : 0;
200
207
 
201
- /* ── final height ─────────────────────────────────────────── */
202
- const H = barY + (hasLangs ? langSectionH : 0) + 12 + (hasLangs ? 0 : 4);
208
+ /* ── final height ────────────────────────────────────────── */
209
+ const H = barY + (hasLangs ? langSectionH : 0) + 14 + (hasLangs ? 0 : 6);
203
210
 
204
- /* ── build SVG ────────────────────────────────────────────── */
211
+ /* ── accessibility ────────────────────────────────────────── */
205
212
  const parts: string[] = [];
206
-
207
- // root
213
+ const svgAttrs: Record<string, string | number> = {
214
+ id: "root",
215
+ width: W,
216
+ height: H,
217
+ viewBox: `0 0 ${W} ${H}`,
218
+ fill: "none",
219
+ xmlns: "http://www.w3.org/2000/svg",
220
+ role: "img",
221
+ "aria-labelledby": "cardTitle cardDesc",
222
+ };
223
+ let svgOpen = "<svg";
224
+ for (const [k, v] of Object.entries(svgAttrs)) svgOpen += ` ${k}="${escapeXml(String(v))}"`;
225
+ parts.push(svgOpen + ">");
226
+ parts.push(el("title", { id: "cardTitle" }, `${escapeXml(displayName)}'s GitHub Stats`));
208
227
  parts.push(
209
- '<svg width="' +
210
- W +
211
- '" height="' +
212
- H +
213
- '" viewBox="0 0 ' +
214
- W +
215
- " " +
216
- H +
217
- '" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="cardTitle cardDesc">',
228
+ el(
229
+ "desc",
230
+ { id: "cardDesc" },
231
+ escapeXml(
232
+ `GitHub profile card for ${displayName}. ${visible
233
+ .map(({ key, label }) => {
234
+ const value = stats[key];
235
+ return value === null ? `${label}: unavailable` : `${label}: ${kFormat(value)}`;
236
+ })
237
+ .join(", ")}. ${activeLangsList.length} highlighted languages.`,
238
+ ),
239
+ ),
218
240
  );
219
241
 
220
- // accessibility
221
- parts.push('<title id="cardTitle">' + escapeXml(displayName + "'s GitHub Stats") + "</title>");
222
- parts.push(
223
- '<desc id="cardDesc">GitHub profile card for ' +
224
- escapeXml(displayName) +
225
- " with " +
226
- kFormat(stats.stars) +
227
- " stars, " +
228
- kFormat(stats.commits) +
229
- " commits, and " +
230
- activeLangsList.length +
231
- " highlighted languages.</desc>",
242
+ /* ── styles ──────────────────────────────────────────────── */
243
+ const css: string[] = [];
244
+ css.push(
245
+ `${selector} { font-family: ${FONT_FAMILY}; text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`,
232
246
  );
233
-
234
- // ── <style> block ──────────────────────────────────────────
235
- const cssLines: string[] = [];
236
- cssLines.push("* { font-family: " + fontFamily + "; }");
237
- cssLines.push(".header { font-size: 18px; font-weight: 700; fill: " + titleColor + "; }");
238
- cssLines.push(".stat { font-size: 14px; font-weight: 600; fill: " + textColor + "; }");
239
- cssLines.push(".bold { font-weight: 700; }");
240
- cssLines.push(".icon { fill: " + iconColor + "; }");
241
- cssLines.push(".lang-name { font-size: 11px; fill: " + textColor + "; opacity: 0.8; }");
242
- cssLines.push(".lang-progress { fill: " + textColor + "; opacity: 0.08; }");
243
- cssLines.push(
244
- ".meta { font-size: 12px; font-weight: 500; fill: " + textColor + "; opacity: 0.7; }",
247
+ css.push(
248
+ `${selector} .header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
245
249
  );
246
- cssLines.push(
247
- ".bio { font-size: 11px; font-weight: 400; fill: " + textColor + "; opacity: 0.68; }",
250
+ css.push(`${selector} .lang-name { font-size: 11px; fill: ${textColor}; opacity: 0.82; }`);
251
+ css.push(`${selector} .lang-progress { fill: ${textColor}; opacity: 0.07; }`);
252
+ css.push(
253
+ `${selector} .meta { font-size: 12.5px; font-weight: 500; fill: ${textColor}; opacity: 0.72; }`,
248
254
  );
249
- cssLines.push(
250
- ".stat-label { font-size: 9px; font-weight: 600; fill: " +
251
- textColor +
252
- "; opacity: 0.5; letter-spacing: 0.08em; }",
255
+ css.push(
256
+ `${selector} .bio { font-size: 11.5px; font-weight: 400; fill: ${textColor}; opacity: 0.68; }`,
253
257
  );
254
- cssLines.push(".stat-value { font-size: 15px; font-weight: 800; fill: " + textColor + "; }");
255
- cssLines.push(
256
- ".twitter-text { font-size: 11px; font-weight: 500; fill: " + textColor + "; opacity: 0.65; }",
258
+ css.push(
259
+ `${selector} .stat-label { font-size: 10px; font-weight: 700; fill: ${textColor}; opacity: 0.68; letter-spacing: 0.06em; }`,
260
+ );
261
+ css.push(
262
+ `${selector} .stat-value { font-size: 16px; font-weight: 700; fill: ${textColor}; letter-spacing: -0.015em; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }`,
263
+ );
264
+ css.push(
265
+ `${selector} .twitter-text { font-size: 11.5px; font-weight: 500; fill: ${textColor}; opacity: 0.65; }`,
257
266
  );
258
267
 
259
268
  if (animate) {
260
- cssLines.push("@keyframes fadeInAnimation { from { opacity: 0; } to { opacity: 1; } }");
261
- cssLines.push(".stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; }");
269
+ css.push(`@keyframes ${namespace}-fade { from { opacity: 0; } to { opacity: 1; } }`);
270
+ css.push(
271
+ `${selector} .stagger { opacity: 0; animation: ${namespace}-fade 0.3s ease-in-out forwards; }`,
272
+ );
262
273
  }
263
274
 
264
- parts.push("<style>" + cssLines.join(" ") + "</style>");
275
+ parts.push(el("style", {}, escapeXml(css.join(" "))));
265
276
 
266
- // ── <defs> ─────────────────────────────────────────────────
267
- parts.push("<defs>");
268
- parts.push(
269
- '<clipPath id="av"><circle cx="' +
270
- (PX + avatarR) +
271
- '" cy="' +
272
- (PY + avatarR) +
273
- '" r="' +
274
- avatarR +
275
- '"/></clipPath>',
276
- );
277
- parts.push(
278
- '<radialGradient id="gl" cx="12%" cy="15%" r="65%">' +
279
- '<stop offset="0%" stop-color="' +
280
- iconColor +
281
- '" stop-opacity="0.08"/>' +
282
- '<stop offset="100%" stop-color="' +
283
- iconColor +
284
- '" stop-opacity="0"/>' +
285
- "</radialGradient>",
286
- );
277
+ /* ── defs ────────────────────────────────────────────────── */
287
278
  parts.push(
288
- '<radialGradient id="gl2" cx="92%" cy="95%" r="55%">' +
289
- '<stop offset="0%" stop-color="' +
290
- titleColor +
291
- '" stop-opacity="0.035"/>' +
292
- '<stop offset="100%" stop-color="' +
293
- titleColor +
294
- '" stop-opacity="0"/>' +
295
- "</radialGradient>",
279
+ el(
280
+ "defs",
281
+ {},
282
+ [
283
+ el(
284
+ "clipPath",
285
+ { id: "av" },
286
+ el("circle", { cx: PX + avatarR, cy: PY + avatarR, r: avatarR }),
287
+ ),
288
+ el(
289
+ "radialGradient",
290
+ { id: "gl", cx: "12%", cy: "15%", r: "65%" },
291
+ el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0.08" }) +
292
+ el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
293
+ ),
294
+ el(
295
+ "radialGradient",
296
+ { id: "gl2", cx: "92%", cy: "95%", r: "55%" },
297
+ el("stop", { offset: "0%", "stop-color": titleColor, "stop-opacity": "0.035" }) +
298
+ el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0" }),
299
+ ),
300
+ el(
301
+ "linearGradient",
302
+ { id: "ring", x1: "0", y1: "0", x2: "1", y2: "1" },
303
+ el("stop", { offset: "0%", "stop-color": titleColor }) +
304
+ el("stop", { offset: "100%", "stop-color": iconColor }),
305
+ ),
306
+ el(
307
+ "linearGradient",
308
+ { id: "card-bg", x1: "0", y1: "0", x2: W, y2: H, gradientUnits: "userSpaceOnUse" },
309
+ el("stop", { offset: "0%", "stop-color": bgColor }) +
310
+ el("stop", { offset: "82%", "stop-color": bgColor }) +
311
+ el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0.04" }),
312
+ ),
313
+ el(
314
+ "linearGradient",
315
+ { id: "rim", x1: "0", y1: "0", x2: W, y2: "0", gradientUnits: "userSpaceOnUse" },
316
+ el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0" }) +
317
+ el("stop", { offset: "45%", "stop-color": iconColor, "stop-opacity": "0.28" }) +
318
+ el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
319
+ ),
320
+ ].join(""),
321
+ ),
296
322
  );
297
- parts.push(
298
- '<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">' +
299
- '<stop offset="0%" stop-color="' +
300
- titleColor +
301
- '"/>' +
302
- '<stop offset="100%" stop-color="' +
303
- iconColor +
304
- '"/>' +
305
- "</linearGradient>",
306
- );
307
- parts.push(
308
- '<linearGradient id="card-bg" x1="0" y1="0" x2="' +
309
- W +
310
- '" y2="' +
311
- H +
312
- '" gradientUnits="userSpaceOnUse">' +
313
- '<stop offset="0%" stop-color="' +
314
- bgColor +
315
- '"/>' +
316
- '<stop offset="82%" stop-color="' +
317
- bgColor +
318
- '"/>' +
319
- '<stop offset="100%" stop-color="' +
320
- titleColor +
321
- '" stop-opacity="0.04"/>' +
322
- "</linearGradient>",
323
- );
324
- parts.push(
325
- '<linearGradient id="rim" x1="0" y1="0" x2="' +
326
- W +
327
- '" y2="0" gradientUnits="userSpaceOnUse">' +
328
- '<stop offset="0%" stop-color="' +
329
- iconColor +
330
- '" stop-opacity="0"/>' +
331
- '<stop offset="45%" stop-color="' +
332
- iconColor +
333
- '" stop-opacity="0.28"/>' +
334
- '<stop offset="100%" stop-color="' +
335
- iconColor +
336
- '" stop-opacity="0"/>' +
337
- "</linearGradient>",
338
- );
339
- parts.push("</defs>");
340
323
 
341
- // ── background rect (inset 0.5px for clean border) ─────────
324
+ /* ── background & glow ───────────────────────────────────── */
342
325
  parts.push(
343
- '<rect x="0.5" y="0.5" width="' +
344
- (W - 1) +
345
- '" height="' +
346
- (H - 1) +
347
- '" rx="10" fill="' +
348
- "url(#card-bg)" +
349
- '" stroke-opacity="1"/>',
326
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#card-bg)" }),
350
327
  );
351
-
352
- // glow overlays (icon tint top-left, title tint bottom-right)
353
328
  parts.push(
354
- '<rect x="0.5" y="0.5" width="' +
355
- (W - 1) +
356
- '" height="' +
357
- (H - 1) +
358
- '" rx="10" fill="url(#gl)"/>',
329
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl)" }),
359
330
  );
360
331
  parts.push(
361
- '<rect x="0.5" y="0.5" width="' +
362
- (W - 1) +
363
- '" height="' +
364
- (H - 1) +
365
- '" rx="10" fill="url(#gl2)"/>',
332
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl2)" }),
366
333
  );
367
334
  parts.push(
368
- '<line x1="' +
369
- PX +
370
- '" y1="1.5" x2="' +
371
- (W - PX) +
372
- '" y2="1.5" stroke="url(#rim)" stroke-width="1"/>',
335
+ el("line", {
336
+ x1: PX,
337
+ y1: "1.5",
338
+ x2: W - PX,
339
+ y2: "1.5",
340
+ stroke: "url(#rim)",
341
+ "stroke-width": 1,
342
+ }),
373
343
  );
374
344
 
375
- // border
376
345
  if (!hideBorder) {
377
346
  parts.push(
378
- '<rect x="0.5" y="0.5" width="' +
379
- (W - 1) +
380
- '" height="' +
381
- (H - 1) +
382
- '" rx="10" fill="none" stroke="' +
383
- borderColor +
384
- '" stroke-opacity="0.5"/>',
347
+ el("rect", {
348
+ x: "0.5",
349
+ y: "0.5",
350
+ width: W - 1,
351
+ height: H - 1,
352
+ rx: 12,
353
+ fill: "none",
354
+ stroke: borderColor,
355
+ "stroke-opacity": "0.5",
356
+ }),
385
357
  );
386
358
  }
387
359
 
388
- // ── avatar ─────────────────────────────────────────────────
360
+ /* ── avatar ───────────────────────────────────────────────── */
389
361
  parts.push(
390
- '<g transform="translate(0,0)">' +
391
- '<circle cx="' +
392
- (PX + avatarR + 1) +
393
- '" cy="' +
394
- (PY + avatarR + 1) +
395
- '" r="' +
396
- (avatarR + 3) +
397
- '" fill="' +
398
- titleColor +
399
- '" opacity="0.08"/>' +
400
- '<image href="' +
401
- avatar +
402
- '" x="' +
403
- PX +
404
- '" y="' +
405
- PY +
406
- '" width="' +
407
- avatarSize +
408
- '" height="' +
409
- avatarSize +
410
- '" clip-path="url(#av)" preserveAspectRatio="xMidYMid slice"/>' +
411
- '<circle cx="' +
412
- (PX + avatarR) +
413
- '" cy="' +
414
- (PY + avatarR) +
415
- '" r="' +
416
- (avatarR + 1.5) +
417
- '" fill="none" stroke="url(#ring)" stroke-opacity="0.55" stroke-width="1.5"/>' +
418
- "</g>",
362
+ el(
363
+ "g",
364
+ {},
365
+ el("circle", {
366
+ cx: PX + avatarR + 1,
367
+ cy: PY + avatarR + 1,
368
+ r: avatarR + 3,
369
+ fill: titleColor,
370
+ opacity: "0.08",
371
+ }) +
372
+ el("image", {
373
+ href: avatar,
374
+ x: PX,
375
+ y: PY,
376
+ width: avatarSize,
377
+ height: avatarSize,
378
+ "clip-path": "url(#av)",
379
+ preserveAspectRatio: "xMidYMid slice",
380
+ }) +
381
+ el("circle", {
382
+ cx: PX + avatarR,
383
+ cy: PY + avatarR,
384
+ r: avatarR + 1.5,
385
+ fill: "none",
386
+ stroke: "url(#ring)",
387
+ "stroke-opacity": "0.55",
388
+ "stroke-width": "1.5",
389
+ }),
390
+ ),
419
391
  );
420
392
 
421
- // ── profile text ───────────────────────────────────────────
422
- parts.push('<g transform="translate(0,0)">');
423
-
424
- // name
425
- parts.push('<text x="' + infoX + '" y="' + nameY + '" class="header">' + nameEsc + "</text>");
426
-
427
- // username + pronouns
428
- const metaStr = pronouns ? "@" + loginEsc + " \u00b7 " + pronouns : "@" + loginEsc;
429
- parts.push('<text x="' + infoX + '" y="' + loginY + '" class="meta">' + metaStr + "</text>");
430
-
431
- // bio
393
+ /* ── profile text ─────────────────────────────────────────── */
394
+ const textParts: string[] = [];
395
+ textParts.push(el("text", { x: infoX, y: nameY, class: "header" }, nameEsc));
396
+ textParts.push(el("text", { x: infoX, y: loginY, class: "meta" }, metaStr));
432
397
  for (let i = 0; i < bioLines.length; i++) {
433
- parts.push(
434
- '<text x="' +
435
- infoX +
436
- '" y="' +
437
- (bioStartY + i * 14) +
438
- '" class="bio">' +
439
- escapeXml(bioLines[i]!.trim()) +
440
- "</text>",
398
+ textParts.push(
399
+ el("text", { x: infoX, y: bioStartY + i * 14, class: "bio" }, escapeXml(bioLines[i]!.trim())),
441
400
  );
442
401
  }
443
-
444
- // twitter
445
402
  if (twitter) {
446
- parts.push(
447
- '<g transform="translate(' +
448
- infoX +
449
- "," +
450
- (twitterY - 9) +
451
- ')">' +
452
- icon("x", textColor, 11) +
453
- '<text x="15" y="9" class="twitter-text">@' +
454
- twitter +
455
- "</text></g>",
403
+ textParts.push(
404
+ el(
405
+ "g",
406
+ { transform: `translate(${infoX},${twitterY - 9})` },
407
+ icon("x", textColor, 11) + el("text", { x: 15, y: 9, class: "twitter-text" }, twitter),
408
+ ),
456
409
  );
457
410
  }
411
+ parts.push(el("g", {}, textParts.join("")));
458
412
 
459
- parts.push("</g>");
460
-
461
- // ── stats row ──────────────────────────────────────────────
413
+ /* ── stats row ───────────────────────────────────────────── */
462
414
  if (visible.length > 0) {
463
- parts.push(
464
- '<rect x="' +
465
- PX +
466
- '" y="' +
467
- (statsY - 8) +
468
- '" width="' +
469
- contentW +
470
- '" height="' +
471
- (statsH + 8) +
472
- '" rx="8" fill="' +
473
- textColor +
474
- '" opacity="0.035"/>',
475
- );
476
-
477
- parts.push('<g transform="translate(0,0)">');
415
+ const statParts: string[] = [];
478
416
 
479
417
  for (let i = 0; i < visible.length; i++) {
480
418
  const d = visible[i]!;
481
- const val = stats[d.key as keyof UserStats];
419
+ const val = stats[d.key];
482
420
  const cx = PX + statW * i + statW / 2;
483
- const cellX = PX + statW * i + 4;
484
- const iy = statsY + 2;
485
-
486
- const staggerStyle = animate ? ' style="animation-delay: ' + (i + 3) * 150 + 'ms"' : "";
487
- const staggerClass = animate ? " stagger" : "";
488
-
489
- parts.push(
490
- '<rect x="' +
491
- cellX +
492
- '" y="' +
493
- (statsY - 4) +
494
- '" width="' +
495
- Math.max(0, statW - 8) +
496
- '" height="' +
497
- (statsH - 2) +
498
- '" rx="8" fill="' +
499
- textColor +
500
- '" opacity="0.028"/>',
421
+ const iy = statsY + 4;
422
+
423
+ // Vertical stack: icon → value → label, all centered, with airy gaps.
424
+ const iconSize = 15;
425
+ const iconY = 2;
426
+ const valueY = 37;
427
+ const labelY = 52;
428
+
429
+ const cellParts: string[] = [
430
+ el(
431
+ "title",
432
+ {},
433
+ val === null ? `${d.label}: unavailable` : `${d.label}: ${val.toLocaleString("en-US")}`,
434
+ ),
435
+ ];
436
+ cellParts.push(
437
+ el(
438
+ "g",
439
+ { transform: `translate(${-iconSize / 2},${iconY})` },
440
+ icon(d.iconName, iconColor, iconSize),
441
+ ),
501
442
  );
502
-
503
- // each stat wrapped in its own <g>
504
- parts.push(
505
- '<g transform="translate(' +
506
- cx +
507
- "," +
508
- iy +
509
- ')" class="stat' +
510
- staggerClass +
511
- '"' +
512
- staggerStyle +
513
- ">",
443
+ cellParts.push(
444
+ el(
445
+ "text",
446
+ { x: 0, y: valueY, class: "stat-value", "text-anchor": "middle" },
447
+ val === null ? "—" : kFormat(val),
448
+ ),
514
449
  );
515
-
516
- // value
517
- parts.push(
518
- '<text x="0" y="26" class="stat-value" text-anchor="middle">' + kFormat(val) + "</text>",
450
+ cellParts.push(
451
+ el(
452
+ "text",
453
+ { x: 0, y: labelY, class: "stat-label", "text-anchor": "middle" },
454
+ d.label.toUpperCase(),
455
+ ),
519
456
  );
520
457
 
521
- // label with tiny inline icon, centered as a unit
522
- const labelText = d.label.toUpperCase();
523
- const labelW = labelText.length * 6.8;
524
- const rowW = 10 + 4 + labelW;
525
- parts.push(
526
- '<g transform="translate(' +
527
- -rowW / 2 +
528
- ',31.5)">' +
529
- icon(d.iconName, iconColor, 10) +
530
- "</g>",
531
- );
532
- parts.push(
533
- '<text x="' + (-rowW / 2 + 14) + '" y="40" class="stat-label">' + labelText + "</text>",
458
+ statParts.push(
459
+ el(
460
+ "g",
461
+ {
462
+ transform: `translate(${cx},${iy})`,
463
+ class: animate ? "stagger" : undefined,
464
+ style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
465
+ },
466
+ cellParts.join(""),
467
+ ),
534
468
  );
535
469
 
536
- parts.push("</g>");
470
+ // Subtle divider between stats (not after the last one).
471
+ if (i < visible.length - 1) {
472
+ const dx = PX + statW * (i + 1);
473
+ statParts.push(
474
+ el("line", {
475
+ x1: dx,
476
+ y1: statsY + 4,
477
+ x2: dx,
478
+ y2: statsY + statsH - 6,
479
+ stroke: textColor,
480
+ "stroke-opacity": "0.12",
481
+ }),
482
+ );
483
+ }
537
484
  }
538
-
539
- parts.push("</g>");
485
+ parts.push(el("g", {}, statParts.join("")));
540
486
  }
541
487
 
542
- // ── languages ──────────────────────────────────────────────
488
+ /* ── languages ───────────────────────────────────────────── */
543
489
  if (hasLangs) {
544
- // separator
545
- parts.push(
546
- '<line x1="' +
547
- PX +
548
- '" y1="' +
549
- (barY - 8) +
550
- '" x2="' +
551
- (W - PX) +
552
- '" y2="' +
553
- (barY - 8) +
554
- '" stroke="' +
555
- textColor +
556
- '" stroke-opacity="0.08"/>',
490
+ const langParts: string[] = [];
491
+ langParts.push(
492
+ el("line", {
493
+ x1: PX,
494
+ y1: barY - 8,
495
+ x2: W - PX,
496
+ y2: barY - 8,
497
+ stroke: textColor,
498
+ "stroke-opacity": "0.08",
499
+ }),
557
500
  );
558
501
 
559
- parts.push('<g transform="translate(0,0)">');
560
-
561
- // track background & clip path for rounded corners
562
- parts.push(
563
- '<clipPath id="lang-clip"><rect x="' +
564
- PX +
565
- '" y="' +
566
- barY +
567
- '" width="' +
568
- (animate ? "0" : contentW) +
569
- '" height="' +
570
- barH +
571
- '" rx="5">' +
572
- (animate
573
- ? '<animate attributeName="width" from="0" to="' +
574
- contentW +
575
- '" dur="0.6s" fill="freeze"/>'
576
- : "") +
577
- "</rect></clipPath>",
502
+ langParts.push(
503
+ el(
504
+ "clipPath",
505
+ { id: "lang-clip" },
506
+ el(
507
+ "rect",
508
+ {
509
+ x: PX,
510
+ y: barY,
511
+ width: animate ? "0" : contentW,
512
+ height: barH,
513
+ rx: 5,
514
+ },
515
+ animate
516
+ ? el("animate", {
517
+ attributeName: "width",
518
+ from: "0",
519
+ to: contentW,
520
+ dur: "0.6s",
521
+ fill: "freeze",
522
+ })
523
+ : "",
524
+ ),
525
+ ),
578
526
  );
579
- parts.push(
580
- '<rect x="' +
581
- PX +
582
- '" y="' +
583
- barY +
584
- '" width="' +
585
- contentW +
586
- '" height="' +
587
- barH +
588
- '" rx="5" class="lang-progress"/>',
527
+ langParts.push(
528
+ el("rect", { x: PX, y: barY, width: contentW, height: barH, rx: 5, class: "lang-progress" }),
589
529
  );
590
530
 
591
- parts.push('<g clip-path="url(#lang-clip)">');
592
-
593
- const activeLangs = visualLangs;
531
+ const barParts: string[] = [];
594
532
  let off = 0;
595
- for (let i = 0; i < activeLangs.length; i++) {
596
- const lang = activeLangs[i]!;
533
+ for (let i = 0; i < visualLangs.length; i++) {
534
+ const lang = visualLangs[i]!;
597
535
  const pct = ((lang.size / totalSize) * 100).toFixed(1);
598
- let w = i === activeLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
536
+ let w = i === visualLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
599
537
  if (w < 0) w = 0;
600
538
  const x = PX + off;
601
-
602
- const tooltip = compact ? "" : "<title>" + escapeXml(lang.name) + " " + pct + "%</title>";
603
-
604
- parts.push(
605
- '<rect x="' +
606
- x +
607
- '" y="' +
608
- barY +
609
- '" width="' +
610
- w +
611
- '" height="' +
612
- barH +
613
- '" fill="' +
614
- lang.color +
615
- '">' +
616
- tooltip +
617
- "</rect>",
539
+ const tooltip = compact ? "" : el("title", {}, `${escapeXml(lang.name)} ${pct}%`);
540
+ barParts.push(
541
+ el("rect", { x, y: barY, width: w, height: barH, fill: raw(lang.color) }, tooltip),
618
542
  );
619
543
  off += w;
620
544
  }
545
+ langParts.push(el("g", { "clip-path": "url(#lang-clip)" }, barParts.join("")));
621
546
 
622
- parts.push("</g>");
623
-
624
- // legend (3-column grid)
625
547
  for (let i = 0; i < legendItems.length; i++) {
626
548
  const item = legendItems[i]!;
627
549
  const col = i % legendCols;
@@ -629,30 +551,33 @@ export function renderCard(
629
551
  const lx = PX + col * legendColW;
630
552
  const ly = legendY + row * legendRowH;
631
553
 
632
- const staggerStyle = animate ? ' style="animation-delay: ' + (i + 3) * 150 + 'ms"' : "";
633
- const staggerClass = animate ? ' class="stagger"' : "";
634
-
635
- parts.push("<g" + staggerClass + staggerStyle + ">");
636
- parts.push('<circle cx="' + (lx + 5) + '" cy="' + ly + '" r="4" fill="' + item.color + '"/>');
637
- parts.push(
638
- '<text x="' +
639
- (lx + 14) +
640
- '" y="' +
641
- (ly + 4) +
642
- '" class="lang-name" font-weight="500">' +
643
- escapeXml(item.name) +
644
- " " +
645
- item.pct +
646
- "%</text>",
554
+ langParts.push(
555
+ el(
556
+ "g",
557
+ {
558
+ class: animate ? "stagger" : undefined,
559
+ style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
560
+ },
561
+ el("circle", { cx: lx + 5, cy: ly, r: 4, fill: item.color }) +
562
+ el(
563
+ "text",
564
+ { x: lx + 14, y: ly + 4, class: "lang-name", "font-weight": "500" },
565
+ `${escapeXml(item.name)} ${item.pct}%`,
566
+ ),
567
+ ),
647
568
  );
648
- parts.push("</g>");
649
569
  }
650
570
 
651
- parts.push("</g>");
571
+ parts.push(el("g", {}, langParts.join("")));
652
572
  }
653
573
 
654
- // close
655
574
  parts.push("</svg>");
656
-
657
- return parts.join("");
575
+ return parts
576
+ .join("")
577
+ .replace(/\bid="([^"]+)"/g, (_, id: string) => `id="${namespace}-${id}"`)
578
+ .replace(/="url\(#([^)]+)\)"/g, (_, id: string) => `="url(#${namespace}-${id})"`)
579
+ .replace(
580
+ 'aria-labelledby="cardTitle cardDesc"',
581
+ `aria-labelledby="${namespace}-cardTitle ${namespace}-cardDesc"`,
582
+ );
658
583
  }