@shiinasaku/github-card 4.1.0 → 4.2.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
@@ -4,6 +4,7 @@ import {
4
4
  escapeXml,
5
5
  icon,
6
6
  wrapText,
7
+ measureText,
7
8
  FONT_FAMILY,
8
9
  resolveTw,
9
10
  } from "./utils/index";
@@ -38,6 +39,22 @@ type LangVisual = {
38
39
  size: number;
39
40
  };
40
41
 
42
+ /* ── tiny SVG builder ──────────────────────────────────────── */
43
+
44
+ function el(
45
+ tag: string,
46
+ attrs: Record<string, string | number | undefined>,
47
+ children = "",
48
+ ): string {
49
+ let a = "";
50
+ for (const [k, v] of Object.entries(attrs)) {
51
+ if (v === undefined || v === null || v === "") continue;
52
+ a += ` ${k}="${escapeXml(String(v))}"`;
53
+ }
54
+ if (!children) return `<${tag}${a} />`;
55
+ return `<${tag}${a}>${children}</${tag}>`;
56
+ }
57
+
41
58
  /* ── helpers ───────────────────────────────────────────────── */
42
59
 
43
60
  function raw(token: string): string {
@@ -64,7 +81,6 @@ function buildLanguageSegments(
64
81
  tinySize += lang.size;
65
82
  continue;
66
83
  }
67
-
68
84
  visible.push(lang);
69
85
  }
70
86
 
@@ -97,10 +113,9 @@ export function renderCard(
97
113
  const iconColor = raw(c.icon);
98
114
  const borderColor = raw(c.border);
99
115
 
100
- /* ── font (XML-safe) ──────────────────────────────────────── */
101
- const fontFamily = FONT_FAMILY.replace(/"/g, "&apos;");
116
+ const fontFamily = FONT_FAMILY;
102
117
 
103
- /* ── profile text setup ───────────────────────────────────── */
118
+ /* ── profile text ─────────────────────────────────────────── */
104
119
  const displayName = user.name || user.login;
105
120
  const nameEsc = escapeXml(clamp(displayName, 28));
106
121
  const loginEsc = escapeXml(clamp(user.login, 24));
@@ -108,7 +123,8 @@ export function renderCard(
108
123
  const bioRaw = !compact && user.bio ? user.bio : "";
109
124
  const bioLines = bioRaw ? wrapText(bioRaw, 44, 2) : [];
110
125
  const twitter = !compact && user.twitter ? escapeXml(clamp(user.twitter, 22)) : "";
111
- const avatar = (user.avatarUrl || "").replace(/&/g, "&amp;");
126
+ const avatar = user.avatarUrl || "";
127
+ const metaStr = pronouns ? `@${loginEsc} \u00b7 ${pronouns}` : `@${loginEsc}`;
112
128
 
113
129
  const visible = STAT_DEFS.filter((d) => !hidden.has(d.key));
114
130
  const activeLangsList = langs.filter((l) => {
@@ -118,59 +134,56 @@ export function renderCard(
118
134
  return true;
119
135
  });
120
136
 
121
- /* ── dimensions (dynamic width) ───────────────────────────── */
122
- const PX = 25; // horizontal padding
123
- const PY = 25; // top padding
124
- const avatarSize = 64;
137
+ /* ── dimensions (measured, dynamic width) ───────────────── */
138
+ const PX = 24; // horizontal padding
139
+ const PY = 22; // top padding
140
+ const avatarSize = 60;
125
141
  const avatarR = avatarSize / 2;
142
+ const textGap = 16;
126
143
 
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
- );
144
+ const nameW = measureText(displayName, 17, 700);
145
+ const metaW = measureText(metaStr, 12.5, 500);
146
+ const bioW = bioLines.length ? Math.max(...bioLines.map((l) => measureText(l, 11.5, 400))) : 0;
147
+ const twitterW = twitter ? measureText(`@${twitter}`, 11.5, 500) : 0;
148
+ const profileTextW = Math.max(nameW, metaW, bioW, twitterW);
136
149
 
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
150
+ const profileW = PX + avatarSize + textGap + profileTextW + PX;
151
+ const statsW = visible.length > 0 ? visible.length * 84 + PX * 2 : 0;
152
+ const langsW = activeLangsList.length > 0 ? 300 : 0;
140
153
 
141
154
  const W = Math.round(Math.min(540, Math.max(340, profileW, statsW, langsW)));
142
155
  const contentW = W - PX * 2;
143
156
 
144
- /* ── profile section ──────────────────────────────────────── */
145
- const infoX = PX + avatarSize + 14;
146
- const nameY = PY + 22;
147
- const loginY = nameY + 18;
148
- const bioStartY = loginY + 16;
157
+ /* ── profile section ─────────────────────────────────────── */
158
+ const infoX = PX + avatarSize + textGap;
159
+ const nameY = PY + 20;
160
+ const loginY = nameY + 17;
161
+ const bioStartY = loginY + 15;
149
162
  const twitterY = bioStartY + bioLines.length * 14 + (bioLines.length ? 4 : 0);
150
163
 
151
- let profileH = loginY + 10 - PY;
164
+ let profileH = loginY + 8 - PY;
152
165
  if (bioLines.length) profileH = bioStartY + bioLines.length * 14 - PY;
153
166
  if (twitter) profileH = twitterY + 10 - PY;
154
- profileH = Math.max(profileH, avatarSize + 4);
167
+ profileH = Math.max(profileH, avatarSize + 6);
155
168
 
156
169
  /* ── stats section ────────────────────────────────────────── */
157
- const statsY = PY + profileH + 14;
170
+ const statsY = PY + profileH + 16;
158
171
  const statW = visible.length > 0 ? contentW / visible.length : 0;
159
- const statsH = visible.length > 0 ? 50 : 0;
172
+ const statsH = visible.length > 0 ? 58 : 0;
160
173
 
161
- /* ── languages section ────────────────────────────────────── */
174
+ /* ── languages section ─────────────────────────────────── */
162
175
  const totalSize = activeLangsList.reduce((s, l) => s + l.size, 0) || 1;
163
176
  const sorted = [...activeLangsList].sort((a, b) => b.size - a.size);
164
177
  const visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
165
- const barY = statsY + statsH + (statsH ? 16 : 12);
178
+ const barY = statsY + statsH + (statsH ? 18 : 14);
166
179
  const barH = 10;
167
180
 
168
181
  const maxLegend = compact ? 0 : 6;
169
182
  const legendLangs = visualLangs.slice(0, maxLegend);
170
183
  const otherSize = visualLangs.slice(maxLegend).reduce((s, l) => s + l.size, 0);
171
184
 
172
- const legendY = barY + barH + 12;
173
- const legendRowH = 16;
185
+ const legendY = barY + barH + 14;
186
+ const legendRowH = 17;
174
187
  const legendCols = 3;
175
188
  const legendColW = contentW / legendCols;
176
189
  const legendItems: { name: string; color: string; pct: string }[] = legendLangs.map((l) => ({
@@ -196,432 +209,326 @@ export function renderCard(
196
209
  const legendRows = compact ? 0 : Math.ceil(legendItems.length / legendCols);
197
210
 
198
211
  const hasLangs = activeLangsList.length > 0;
199
- const langSectionH = hasLangs ? barH + 12 + legendRows * legendRowH + 8 : 0;
212
+ const langSectionH = hasLangs ? barH + 14 + legendRows * legendRowH + 10 : 0;
200
213
 
201
- /* ── final height ─────────────────────────────────────────── */
202
- const H = barY + (hasLangs ? langSectionH : 0) + 12 + (hasLangs ? 0 : 4);
214
+ /* ── final height ────────────────────────────────────────── */
215
+ const H = barY + (hasLangs ? langSectionH : 0) + 14 + (hasLangs ? 0 : 6);
203
216
 
204
- /* ── build SVG ────────────────────────────────────────────── */
217
+ /* ── accessibility ────────────────────────────────────────── */
205
218
  const parts: string[] = [];
206
-
207
- // root
219
+ const svgAttrs: Record<string, string | number> = {
220
+ width: W,
221
+ height: H,
222
+ viewBox: `0 0 ${W} ${H}`,
223
+ fill: "none",
224
+ xmlns: "http://www.w3.org/2000/svg",
225
+ role: "img",
226
+ "aria-labelledby": "cardTitle cardDesc",
227
+ };
228
+ let svgOpen = "<svg";
229
+ for (const [k, v] of Object.entries(svgAttrs)) svgOpen += ` ${k}="${escapeXml(String(v))}"`;
230
+ parts.push(svgOpen + ">");
231
+ parts.push(el("title", { id: "cardTitle" }, `${escapeXml(displayName)}'s GitHub Stats`));
208
232
  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">',
233
+ el(
234
+ "desc",
235
+ { id: "cardDesc" },
236
+ `GitHub profile card for ${escapeXml(displayName)} with ${kFormat(stats.stars)} stars, ${kFormat(stats.commits)} commits, and ${activeLangsList.length} highlighted languages.`,
237
+ ),
218
238
  );
219
239
 
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>",
240
+ /* ── styles ──────────────────────────────────────────────── */
241
+ const css: string[] = [];
242
+ css.push(
243
+ `* { font-family: ${fontFamily}; text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`,
232
244
  );
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; }",
245
+ css.push(
246
+ `.header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
245
247
  );
246
- cssLines.push(
247
- ".bio { font-size: 11px; font-weight: 400; fill: " + textColor + "; opacity: 0.68; }",
248
+ css.push(`.stat { font-size: 14px; font-weight: 600; fill: ${textColor}; }`);
249
+ css.push(`.icon { fill: ${iconColor}; }`);
250
+ css.push(`.lang-name { font-size: 11px; fill: ${textColor}; opacity: 0.82; }`);
251
+ css.push(`.lang-progress { fill: ${textColor}; opacity: 0.07; }`);
252
+ css.push(`.meta { font-size: 12.5px; font-weight: 500; fill: ${textColor}; opacity: 0.72; }`);
253
+ css.push(`.bio { font-size: 11.5px; font-weight: 400; fill: ${textColor}; opacity: 0.68; }`);
254
+ css.push(
255
+ `.stat-label { font-size: 10px; font-weight: 700; fill: ${textColor}; opacity: 0.68; letter-spacing: 0.06em; }`,
248
256
  );
249
- cssLines.push(
250
- ".stat-label { font-size: 9px; font-weight: 600; fill: " +
251
- textColor +
252
- "; opacity: 0.5; letter-spacing: 0.08em; }",
257
+ css.push(
258
+ `.stat-value { font-size: 16px; font-weight: 700; fill: ${textColor}; letter-spacing: -0.015em; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }`,
253
259
  );
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; }",
260
+ css.push(
261
+ `.twitter-text { font-size: 11.5px; font-weight: 500; fill: ${textColor}; opacity: 0.65; }`,
257
262
  );
258
263
 
259
264
  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; }");
265
+ css.push("@keyframes fadeInAnimation { from { opacity: 0; } to { opacity: 1; } }");
266
+ css.push(".stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; }");
262
267
  }
263
268
 
264
- parts.push("<style>" + cssLines.join(" ") + "</style>");
269
+ parts.push(el("style", {}, css.join(" ")));
265
270
 
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
- );
271
+ /* ── defs ────────────────────────────────────────────────── */
287
272
  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>",
273
+ el(
274
+ "defs",
275
+ {},
276
+ [
277
+ el(
278
+ "clipPath",
279
+ { id: "av" },
280
+ el("circle", { cx: PX + avatarR, cy: PY + avatarR, r: avatarR }),
281
+ ),
282
+ el(
283
+ "radialGradient",
284
+ { id: "gl", cx: "12%", cy: "15%", r: "65%" },
285
+ el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0.08" }) +
286
+ el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
287
+ ),
288
+ el(
289
+ "radialGradient",
290
+ { id: "gl2", cx: "92%", cy: "95%", r: "55%" },
291
+ el("stop", { offset: "0%", "stop-color": titleColor, "stop-opacity": "0.035" }) +
292
+ el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0" }),
293
+ ),
294
+ el(
295
+ "linearGradient",
296
+ { id: "ring", x1: "0", y1: "0", x2: "1", y2: "1" },
297
+ el("stop", { offset: "0%", "stop-color": titleColor }) +
298
+ el("stop", { offset: "100%", "stop-color": iconColor }),
299
+ ),
300
+ el(
301
+ "linearGradient",
302
+ { id: "card-bg", x1: "0", y1: "0", x2: W, y2: H, gradientUnits: "userSpaceOnUse" },
303
+ el("stop", { offset: "0%", "stop-color": bgColor }) +
304
+ el("stop", { offset: "82%", "stop-color": bgColor }) +
305
+ el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0.04" }),
306
+ ),
307
+ el(
308
+ "linearGradient",
309
+ { id: "rim", x1: "0", y1: "0", x2: W, y2: "0", gradientUnits: "userSpaceOnUse" },
310
+ el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0" }) +
311
+ el("stop", { offset: "45%", "stop-color": iconColor, "stop-opacity": "0.28" }) +
312
+ el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
313
+ ),
314
+ ].join(""),
315
+ ),
296
316
  );
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
317
 
341
- // ── background rect (inset 0.5px for clean border) ─────────
318
+ /* ── background & glow ───────────────────────────────────── */
342
319
  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"/>',
320
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#card-bg)" }),
350
321
  );
351
-
352
- // glow overlays (icon tint top-left, title tint bottom-right)
353
322
  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)"/>',
323
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl)" }),
359
324
  );
360
325
  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)"/>',
326
+ el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl2)" }),
366
327
  );
367
328
  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"/>',
329
+ el("line", {
330
+ x1: PX,
331
+ y1: "1.5",
332
+ x2: W - PX,
333
+ y2: "1.5",
334
+ stroke: "url(#rim)",
335
+ "stroke-width": 1,
336
+ }),
373
337
  );
374
338
 
375
- // border
376
339
  if (!hideBorder) {
377
340
  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"/>',
341
+ el("rect", {
342
+ x: "0.5",
343
+ y: "0.5",
344
+ width: W - 1,
345
+ height: H - 1,
346
+ rx: 12,
347
+ fill: "none",
348
+ stroke: borderColor,
349
+ "stroke-opacity": "0.5",
350
+ }),
385
351
  );
386
352
  }
387
353
 
388
- // ── avatar ─────────────────────────────────────────────────
354
+ /* ── avatar ───────────────────────────────────────────────── */
389
355
  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>",
356
+ el(
357
+ "g",
358
+ {},
359
+ el("circle", {
360
+ cx: PX + avatarR + 1,
361
+ cy: PY + avatarR + 1,
362
+ r: avatarR + 3,
363
+ fill: titleColor,
364
+ opacity: "0.08",
365
+ }) +
366
+ el("image", {
367
+ href: avatar,
368
+ x: PX,
369
+ y: PY,
370
+ width: avatarSize,
371
+ height: avatarSize,
372
+ "clip-path": "url(#av)",
373
+ preserveAspectRatio: "xMidYMid slice",
374
+ }) +
375
+ el("circle", {
376
+ cx: PX + avatarR,
377
+ cy: PY + avatarR,
378
+ r: avatarR + 1.5,
379
+ fill: "none",
380
+ stroke: "url(#ring)",
381
+ "stroke-opacity": "0.55",
382
+ "stroke-width": "1.5",
383
+ }),
384
+ ),
419
385
  );
420
386
 
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
387
+ /* ── profile text ─────────────────────────────────────────── */
388
+ const textParts: string[] = [];
389
+ textParts.push(el("text", { x: infoX, y: nameY, class: "header" }, nameEsc));
390
+ textParts.push(el("text", { x: infoX, y: loginY, class: "meta" }, metaStr));
432
391
  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>",
392
+ textParts.push(
393
+ el("text", { x: infoX, y: bioStartY + i * 14, class: "bio" }, escapeXml(bioLines[i]!.trim())),
441
394
  );
442
395
  }
443
-
444
- // twitter
445
396
  if (twitter) {
446
- parts.push(
447
- '<g transform="translate(' +
448
- infoX +
449
- "," +
450
- (twitterY - 9) +
451
- ')">' +
397
+ textParts.push(
398
+ el(
399
+ "g",
400
+ { transform: `translate(${infoX},${twitterY - 9})` },
452
401
  icon("x", textColor, 11) +
453
- '<text x="15" y="9" class="twitter-text">@' +
454
- twitter +
455
- "</text></g>",
402
+ el("text", { x: 15, y: 9, class: "twitter-text" }, `@${twitter}`),
403
+ ),
456
404
  );
457
405
  }
406
+ parts.push(el("g", {}, textParts.join("")));
458
407
 
459
- parts.push("</g>");
460
-
461
- // ── stats row ──────────────────────────────────────────────
408
+ /* ── stats row ───────────────────────────────────────────── */
462
409
  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)">');
410
+ const statParts: string[] = [];
478
411
 
479
412
  for (let i = 0; i < visible.length; i++) {
480
413
  const d = visible[i]!;
481
414
  const val = stats[d.key as keyof UserStats];
482
415
  const cx = PX + statW * i + statW / 2;
483
- const cellX = PX + statW * i + 4;
484
- const iy = statsY + 2;
416
+ const iy = statsY + 4;
485
417
 
486
- const staggerStyle = animate ? ' style="animation-delay: ' + (i + 3) * 150 + 'ms"' : "";
487
418
  const staggerClass = animate ? " stagger" : "";
488
419
 
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"/>',
420
+ // Vertical stack: icon → value → label, all centered, with airy gaps.
421
+ const iconSize = 15;
422
+ const iconY = 2;
423
+ const valueY = 37;
424
+ const labelY = 52;
425
+
426
+ const cellParts: string[] = [];
427
+ cellParts.push(
428
+ el(
429
+ "g",
430
+ { transform: `translate(${-iconSize / 2},${iconY})`, class: "stat-icon" },
431
+ icon(d.iconName, iconColor, iconSize),
432
+ ),
501
433
  );
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
- ">",
434
+ cellParts.push(
435
+ el("text", { x: 0, y: valueY, class: "stat-value", "text-anchor": "middle" }, kFormat(val)),
514
436
  );
515
-
516
- // value
517
- parts.push(
518
- '<text x="0" y="26" class="stat-value" text-anchor="middle">' + kFormat(val) + "</text>",
437
+ cellParts.push(
438
+ el(
439
+ "text",
440
+ { x: 0, y: labelY, class: "stat-label", "text-anchor": "middle" },
441
+ d.label.toUpperCase(),
442
+ ),
519
443
  );
520
444
 
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>",
445
+ statParts.push(
446
+ el(
447
+ "g",
448
+ {
449
+ transform: `translate(${cx},${iy})`,
450
+ class: `stat${staggerClass}`,
451
+ style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
452
+ },
453
+ cellParts.join(""),
454
+ ),
534
455
  );
535
456
 
536
- parts.push("</g>");
457
+ // Subtle divider between stats (not after the last one).
458
+ if (i < visible.length - 1) {
459
+ const dx = PX + statW * (i + 1);
460
+ statParts.push(
461
+ el("line", {
462
+ x1: dx,
463
+ y1: statsY + 4,
464
+ x2: dx,
465
+ y2: statsY + statsH - 6,
466
+ stroke: textColor,
467
+ "stroke-opacity": "0.12",
468
+ }),
469
+ );
470
+ }
537
471
  }
538
-
539
- parts.push("</g>");
472
+ parts.push(el("g", {}, statParts.join("")));
540
473
  }
541
474
 
542
- // ── languages ──────────────────────────────────────────────
475
+ /* ── languages ───────────────────────────────────────────── */
543
476
  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"/>',
477
+ const langParts: string[] = [];
478
+ langParts.push(
479
+ el("line", {
480
+ x1: PX,
481
+ y1: barY - 8,
482
+ x2: W - PX,
483
+ y2: barY - 8,
484
+ stroke: textColor,
485
+ "stroke-opacity": "0.08",
486
+ }),
557
487
  );
558
488
 
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>",
489
+ langParts.push(
490
+ el(
491
+ "clipPath",
492
+ { id: "lang-clip" },
493
+ el(
494
+ "rect",
495
+ {
496
+ x: PX,
497
+ y: barY,
498
+ width: animate ? "0" : contentW,
499
+ height: barH,
500
+ rx: 5,
501
+ },
502
+ animate
503
+ ? el("animate", {
504
+ attributeName: "width",
505
+ from: "0",
506
+ to: contentW,
507
+ dur: "0.6s",
508
+ fill: "freeze",
509
+ })
510
+ : "",
511
+ ),
512
+ ),
578
513
  );
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"/>',
514
+ langParts.push(
515
+ el("rect", { x: PX, y: barY, width: contentW, height: barH, rx: 5, class: "lang-progress" }),
589
516
  );
590
517
 
591
- parts.push('<g clip-path="url(#lang-clip)">');
592
-
593
- const activeLangs = visualLangs;
518
+ const barParts: string[] = [];
594
519
  let off = 0;
595
- for (let i = 0; i < activeLangs.length; i++) {
596
- const lang = activeLangs[i]!;
520
+ for (let i = 0; i < visualLangs.length; i++) {
521
+ const lang = visualLangs[i]!;
597
522
  const pct = ((lang.size / totalSize) * 100).toFixed(1);
598
- let w = i === activeLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
523
+ let w = i === visualLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
599
524
  if (w < 0) w = 0;
600
525
  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>",
618
- );
526
+ const tooltip = compact ? "" : el("title", {}, `${escapeXml(lang.name)} ${pct}%`);
527
+ barParts.push(el("rect", { x, y: barY, width: w, height: barH, fill: lang.color }, tooltip));
619
528
  off += w;
620
529
  }
530
+ langParts.push(el("g", { "clip-path": "url(#lang-clip)" }, barParts.join("")));
621
531
 
622
- parts.push("</g>");
623
-
624
- // legend (3-column grid)
625
532
  for (let i = 0; i < legendItems.length; i++) {
626
533
  const item = legendItems[i]!;
627
534
  const col = i % legendCols;
@@ -629,30 +536,26 @@ export function renderCard(
629
536
  const lx = PX + col * legendColW;
630
537
  const ly = legendY + row * legendRowH;
631
538
 
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>",
539
+ langParts.push(
540
+ el(
541
+ "g",
542
+ {
543
+ class: animate ? "stagger" : undefined,
544
+ style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
545
+ },
546
+ el("circle", { cx: lx + 5, cy: ly, r: 4, fill: item.color }) +
547
+ el(
548
+ "text",
549
+ { x: lx + 14, y: ly + 4, class: "lang-name", "font-weight": "500" },
550
+ `${escapeXml(item.name)} ${item.pct}%`,
551
+ ),
552
+ ),
647
553
  );
648
- parts.push("</g>");
649
554
  }
650
555
 
651
- parts.push("</g>");
556
+ parts.push(el("g", {}, langParts.join("")));
652
557
  }
653
558
 
654
- // close
655
559
  parts.push("</svg>");
656
-
657
560
  return parts.join("");
658
561
  }