Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

contributors.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

contributors.tsxBlame752 lines · 1 contributor
43de941Claude1/**
2 * Contributors page — who contributed to this repo, commit counts.
17f6cacClaude3 *
4 * 2026 polish: scoped `.contrib-*` CSS that mirrors `admin-ops.tsx`
5 * (section cards, gradient hairline, role pills) and `error-page.tsx`
6 * (eyebrow + display headline). The RepoHeader + RepoNav above this
7 * content area are left untouched.
43de941Claude8 */
9
10import { Hono } from "hono";
11import { Layout } from "../views/layout";
12import { RepoHeader, RepoNav } from "../views/components";
13import { getRepoPath, repoExists, getDefaultBranch } from "../git/repository";
14import { softAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16
17const contributors = new Hono<AuthEnv>();
18
19contributors.use("*", softAuth);
20
21interface Contributor {
22 name: string;
23 email: string;
24 commits: number;
25 additions: number;
26 deletions: number;
17f6cacClaude27 lastCommitAt: Date | null;
43de941Claude28}
29
30contributors.get("/:owner/:repo/contributors", async (c) => {
31 const { owner, repo } = c.req.param();
32 const user = c.get("user");
33
34 if (!(await repoExists(owner, repo))) return c.notFound();
35
36 const ref = (await getDefaultBranch(owner, repo)) || "main";
37 const repoDir = getRepoPath(owner, repo);
38
39 // Get shortlog for commit counts
40 const shortlogProc = Bun.spawn(
41 ["git", "shortlog", "-sne", ref],
42 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
43 );
44 const shortlogOut = await new Response(shortlogProc.stdout).text();
45 await shortlogProc.exited;
46
47 const contribs: Contributor[] = shortlogOut
48 .trim()
49 .split("\n")
50 .filter(Boolean)
51 .map((line) => {
52 const match = line.trim().match(/^(\d+)\t(.+?)\s+<(.+?)>$/);
53 if (!match) return null;
54 return {
55 name: match[2],
56 email: match[3],
57 commits: parseInt(match[1], 10),
58 additions: 0,
59 deletions: 0,
17f6cacClaude60 lastCommitAt: null,
61 } as Contributor;
43de941Claude62 })
63 .filter((c): c is Contributor => c !== null)
64 .sort((a, b) => b.commits - a.commits);
65
17f6cacClaude66 // Per-author lines added/removed + most recent commit timestamp.
67 // We use `git log --numstat --format="commit\t%aE\t%aI"` and aggregate by
68 // author email so the totals line up with the shortlog grouping.
69 if (contribs.length > 0) {
70 try {
71 const numstatProc = Bun.spawn(
72 ["git", "log", "--numstat", "--format=__COMMIT__\t%aE\t%aI", ref],
73 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
74 );
75 const numstatOut = await new Response(numstatProc.stdout).text();
76 await numstatProc.exited;
77
78 const byEmail = new Map<string, { add: number; del: number; last: Date | null }>();
79 let currentEmail: string | null = null;
80 let currentDate: Date | null = null;
81 for (const raw of numstatOut.split("\n")) {
82 const line = raw.trimEnd();
83 if (!line) continue;
84 if (line.startsWith("__COMMIT__\t")) {
85 const parts = line.split("\t");
86 currentEmail = (parts[1] || "").toLowerCase();
87 const iso = parts[2] || "";
88 const d = iso ? new Date(iso) : null;
89 currentDate = d && !Number.isNaN(d.getTime()) ? d : null;
90 if (currentEmail && !byEmail.has(currentEmail)) {
91 byEmail.set(currentEmail, { add: 0, del: 0, last: null });
92 }
93 if (currentEmail) {
94 const bucket = byEmail.get(currentEmail)!;
95 if (currentDate && (!bucket.last || currentDate > bucket.last)) {
96 bucket.last = currentDate;
97 }
98 }
99 continue;
100 }
101 if (!currentEmail) continue;
102 // numstat: "<added>\t<removed>\t<path>" — binary files show "-".
103 const m = line.match(/^(\d+|-)\t(\d+|-)\t/);
104 if (!m) continue;
105 const add = m[1] === "-" ? 0 : parseInt(m[1], 10);
106 const del = m[2] === "-" ? 0 : parseInt(m[2], 10);
107 const bucket = byEmail.get(currentEmail)!;
108 bucket.add += add;
109 bucket.del += del;
110 }
111 for (const ctb of contribs) {
112 const bucket = byEmail.get(ctb.email.toLowerCase());
113 if (bucket) {
114 ctb.additions = bucket.add;
115 ctb.deletions = bucket.del;
116 ctb.lastCommitAt = bucket.last;
117 }
118 }
119 } catch {
120 // numstat is a nice-to-have; if it fails we still render commit counts.
121 }
122 }
123
43de941Claude124 // Get recent commit activity (last 52 weeks)
125 const activityProc = Bun.spawn(
126 [
127 "git",
128 "log",
129 "--format=%aI",
130 "--since=1 year ago",
131 ref,
132 ],
133 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
134 );
135 const activityOut = await new Response(activityProc.stdout).text();
136 await activityProc.exited;
137
138 // Build weekly commit counts
139 const weekCounts: number[] = new Array(52).fill(0);
140 const now = Date.now();
141 for (const line of activityOut.trim().split("\n").filter(Boolean)) {
142 const date = new Date(line);
143 const weeksAgo = Math.floor(
144 (now - date.getTime()) / (7 * 24 * 60 * 60 * 1000)
145 );
146 if (weeksAgo >= 0 && weeksAgo < 52) {
147 weekCounts[51 - weeksAgo]++;
148 }
149 }
150
151 const maxWeek = Math.max(...weekCounts, 1);
17f6cacClaude152 const totalCommits = contribs.reduce((s, c) => s + c.commits, 0);
153 const totalAdditions = contribs.reduce((s, c) => s + c.additions, 0);
154 const totalDeletions = contribs.reduce((s, c) => s + c.deletions, 0);
155 const yearCommits = weekCounts.reduce((s, n) => s + n, 0);
43de941Claude156
157 return c.html(
158 <Layout title={`Contributors — ${owner}/${repo}`} user={user}>
159 <RepoHeader owner={owner} repo={repo} />
160 <RepoNav owner={owner} repo={repo} active="code" />
17f6cacClaude161 <div class="contrib-wrap">
162 <header class="contrib-head">
163 <div class="contrib-eyebrow">
164 <span class="contrib-eyebrow-dot" aria-hidden="true" />
165 Repository · Contributors
166 </div>
167 <h1 class="contrib-title">
168 <span class="contrib-title-grad">Who built this.</span>
169 </h1>
170 <p class="contrib-sub">
171 Everyone with a commit on{" "}
172 <code class="contrib-ref">{ref}</code>, ranked by total commits.
173 Bars below show weekly activity over the last year.
174 </p>
175 </header>
176
177 {/* ─── Stats strip ─── */}
178 {contribs.length > 0 && (
179 <div class="contrib-stats">
180 <div class="contrib-stat">
181 <div class="contrib-stat-value">{contribs.length.toLocaleString()}</div>
182 <div class="contrib-stat-label">Contributors</div>
183 </div>
184 <div class="contrib-stat">
185 <div class="contrib-stat-value">{totalCommits.toLocaleString()}</div>
186 <div class="contrib-stat-label">Total commits</div>
187 </div>
188 <div class="contrib-stat">
189 <div class="contrib-stat-value contrib-add">+{totalAdditions.toLocaleString()}</div>
190 <div class="contrib-stat-label">Lines added</div>
191 </div>
192 <div class="contrib-stat">
193 <div class="contrib-stat-value contrib-del">−{totalDeletions.toLocaleString()}</div>
194 <div class="contrib-stat-label">Lines removed</div>
195 </div>
196 </div>
197 )}
198
199 {contribs.length === 0 ? (
200 <div class="contrib-empty">
201 <div class="contrib-empty-orb" aria-hidden="true" />
202 <div class="contrib-empty-inner">
203 <div class="contrib-empty-icon" aria-hidden="true">
204 <IconCommit />
43de941Claude205 </div>
17f6cacClaude206 <h3 class="contrib-empty-title">Push your first commit to see contributors</h3>
207 <p class="contrib-empty-sub">
208 Once anyone pushes to <code>{ref}</code>, they'll appear here
209 ranked by commit count with a year of weekly activity.
210 </p>
43de941Claude211 </div>
17f6cacClaude212 </div>
213 ) : (
214 <>
215 {/* ─── Activity card ─── */}
216 <section class="contrib-section">
217 <header class="contrib-section-head">
218 <div class="contrib-section-head-text">
219 <h2 class="contrib-section-title">
220 <span class="contrib-section-title-icon" aria-hidden="true">
221 <IconBars />
222 </span>
223 Commit activity
224 </h2>
225 <p class="contrib-section-sub">
226 Weekly commit volume on <code class="contrib-ref-inline">{ref}</code> for the
227 last 52 weeks · <strong style="color:var(--text);font-variant-numeric:tabular-nums">{yearCommits.toLocaleString()}</strong> commits this year.
228 </p>
229 </div>
230 </header>
231 <div class="contrib-section-body">
232 <div class="contrib-spark" role="img" aria-label={`${yearCommits} commits in the last 52 weeks`}>
233 {weekCounts.map((count, i) => {
234 const ratio = count / maxWeek;
235 const heightPct = count === 0 ? 4 : Math.max(8, Math.round(ratio * 100));
236 const opacity = count === 0 ? 0.14 : Math.max(0.40, ratio).toFixed(2);
237 const weeksAgo = 51 - i;
238 return (
239 <div
240 class="contrib-spark-bar"
241 title={`${count} commit${count === 1 ? "" : "s"} · ${weeksAgo}w ago`}
242 style={`height:${heightPct}%;opacity:${opacity}`}
243 />
244 );
245 })}
246 </div>
247 <div class="contrib-spark-axis">
248 <span>52w ago</span>
249 <span>26w ago</span>
250 <span>now</span>
251 </div>
252 </div>
253 </section>
254
255 {/* ─── People section ─── */}
256 <section class="contrib-section">
257 <header class="contrib-section-head">
258 <div class="contrib-section-head-text">
259 <h2 class="contrib-section-title">
260 <span class="contrib-section-title-icon" aria-hidden="true">
261 <IconUsers />
262 </span>
263 Contributors
264 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
265 {" "}({contribs.length})
266 </span>
267 </h2>
268 <p class="contrib-section-sub">
269 Ranked by total commits. Click a name to open their profile.
270 </p>
271 </div>
272 </header>
273 <div class="contrib-section-body">
274 <div class="contrib-grid">
275 {contribs.map((ctb, idx) => {
276 const handle = handleFromEmail(ctb.email, ctb.name);
277 const initial = (ctb.name || ctb.email || "?")[0]?.toUpperCase() ?? "?";
278 const role = idx === 0 ? "Maintainer" : "Contributor";
279 const roleClass = idx === 0 ? "contrib-pill is-maintainer" : "contrib-pill is-contributor";
280 return (
281 <div class="contrib-card">
282 <div class="contrib-rank" aria-hidden="true">
283 #{idx + 1}
284 </div>
285 <div class="contrib-avatar" aria-hidden="true">
286 {initial}
287 </div>
288 <div class="contrib-card-body">
289 <div class="contrib-card-row">
290 <a href={`/${handle}`} class="contrib-card-name">
291 {ctb.name}
292 </a>
293 <span class={roleClass}>
294 <span class="dot" aria-hidden="true" />
295 {role}
296 </span>
297 </div>
298 <div class="contrib-card-handle">@{handle}</div>
299 <div class="contrib-meta-row">
300 <span class="contrib-num">{ctb.commits.toLocaleString()}</span>
301 <span class="contrib-meta-label">commit{ctb.commits === 1 ? "" : "s"}</span>
302 {(ctb.additions > 0 || ctb.deletions > 0) && (
303 <>
304 <span class="sep">·</span>
305 <span class="contrib-add contrib-num">+{ctb.additions.toLocaleString()}</span>
306 <span class="contrib-del contrib-num">−{ctb.deletions.toLocaleString()}</span>
307 </>
308 )}
309 {ctb.lastCommitAt && (
310 <>
311 <span class="sep">·</span>
312 <span class="contrib-num" title={ctb.lastCommitAt.toISOString()}>
313 {relativeTime(ctb.lastCommitAt)}
314 </span>
315 </>
316 )}
317 </div>
318 </div>
319 </div>
320 );
321 })}
322 </div>
323 </div>
324 </section>
325 </>
326 )}
327 </div>
328 <style dangerouslySetInnerHTML={{ __html: contribStyles }} />
43de941Claude329 </Layout>
330 );
331});
332
17f6cacClaude333/** Derive a username-ish handle from an email — local part before "@",
334 * stripped of "+suffix" and any non-ident chars. Falls back to the author
335 * name lowercased if the email is unhelpful. */
336function handleFromEmail(email: string, fallbackName: string): string {
337 if (!email) return slugify(fallbackName);
338 const local = email.split("@")[0] || "";
339 const stripped = local.split("+")[0]!.replace(/[^a-zA-Z0-9_-]/g, "");
340 if (stripped) return stripped.toLowerCase();
341 return slugify(fallbackName);
342}
343
344function slugify(s: string): string {
345 return (s || "anon").toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "anon";
346}
347
348function relativeTime(d: Date): string {
349 const diff = Date.now() - d.getTime();
350 const s = Math.max(0, Math.floor(diff / 1000));
351 if (s < 60) return "just now";
352 const m = Math.floor(s / 60);
353 if (m < 60) return `${m}m ago`;
354 const h = Math.floor(m / 60);
355 if (h < 24) return `${h}h ago`;
356 const days = Math.floor(h / 24);
357 if (days < 30) return `${days}d ago`;
358 const months = Math.floor(days / 30);
359 if (months < 12) return `${months}mo ago`;
360 const years = Math.floor(days / 365);
361 return `${years}y ago`;
362}
363
364function IconUsers() {
365 return (
366 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
367 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
368 <circle cx="9" cy="7" r="4" />
369 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
370 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
371 </svg>
372 );
373}
374function IconBars() {
375 return (
376 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
377 <line x1="12" y1="20" x2="12" y2="10" />
378 <line x1="18" y1="20" x2="18" y2="4" />
379 <line x1="6" y1="20" x2="6" y2="16" />
380 </svg>
381 );
382}
383function IconCommit() {
384 return (
385 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
386 <circle cx="12" cy="12" r="3" />
387 <line x1="3" y1="12" x2="9" y2="12" />
388 <line x1="15" y1="12" x2="21" y2="12" />
389 </svg>
390 );
391}
392
393// ─── Scoped CSS (.contrib-*) ────────────────────────────────────────────────
394
395const contribStyles = `
eed4684Claude396 .contrib-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
17f6cacClaude397
398 /* ─── Header strip ─── */
399 .contrib-head { margin-bottom: var(--space-5); }
400 .contrib-eyebrow {
401 display: inline-flex;
402 align-items: center;
403 gap: 8px;
404 text-transform: uppercase;
405 font-family: var(--font-mono);
406 font-size: 11px;
407 letter-spacing: 0.16em;
408 color: var(--text-muted);
409 font-weight: 600;
410 margin-bottom: 10px;
411 }
412 .contrib-eyebrow-dot {
413 width: 8px; height: 8px;
414 border-radius: 9999px;
6fd5915Claude415 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
416 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
17f6cacClaude417 }
418 .contrib-title {
419 font-family: var(--font-display);
420 font-size: clamp(24px, 3.4vw, 36px);
421 font-weight: 800;
422 letter-spacing: -0.028em;
423 line-height: 1.1;
424 margin: 0 0 6px;
425 color: var(--text-strong);
426 }
427 .contrib-title-grad {
6fd5915Claude428 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
17f6cacClaude429 -webkit-background-clip: text;
430 background-clip: text;
431 -webkit-text-fill-color: transparent;
432 color: transparent;
433 }
434 .contrib-sub {
435 margin: 0;
436 font-size: 14px;
437 color: var(--text-muted);
438 line-height: 1.5;
439 max-width: 720px;
440 }
441 .contrib-ref,
442 .contrib-ref-inline {
443 font-family: var(--font-mono);
444 font-size: 12.5px;
445 background: rgba(255,255,255,0.04);
446 border: 1px solid var(--border);
447 padding: 1px 7px;
448 border-radius: 6px;
449 color: var(--text);
450 }
451
452 /* ─── Stats strip ─── */
453 .contrib-stats {
454 display: grid;
455 grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
456 gap: 10px;
457 margin-bottom: var(--space-5);
458 }
459 .contrib-stat {
460 padding: 14px 16px;
461 background: var(--bg-elevated);
462 border: 1px solid var(--border);
463 border-radius: 12px;
464 position: relative;
465 overflow: hidden;
466 }
467 .contrib-stat::before {
468 content: '';
469 position: absolute;
470 top: 0; left: 0; right: 0;
471 height: 1px;
6fd5915Claude472 background: linear-gradient(90deg, transparent, rgba(91,110,232,0.40), rgba(95,143,160,0.30), transparent);
17f6cacClaude473 opacity: 0.55;
474 pointer-events: none;
475 }
476 .contrib-stat-value {
477 font-family: var(--font-display);
478 font-size: 22px;
479 font-weight: 800;
480 color: var(--text-strong);
481 letter-spacing: -0.018em;
482 font-variant-numeric: tabular-nums;
483 line-height: 1.1;
484 }
485 .contrib-stat-label {
486 margin-top: 2px;
487 font-size: 11.5px;
488 color: var(--text-muted);
489 text-transform: uppercase;
490 letter-spacing: 0.06em;
491 font-weight: 600;
492 }
493 .contrib-add { color: #6ee7b7; }
494 .contrib-del { color: #fca5a5; }
495
496 /* ─── Section cards ─── */
497 .contrib-section {
498 margin-bottom: var(--space-5);
499 background: var(--bg-elevated);
500 border: 1px solid var(--border);
501 border-radius: 14px;
502 overflow: hidden;
503 position: relative;
504 }
505 .contrib-section::before {
506 content: '';
507 position: absolute;
508 top: 0; left: 0; right: 0;
509 height: 2px;
6fd5915Claude510 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
17f6cacClaude511 opacity: 0.55;
512 pointer-events: none;
513 }
514 .contrib-section-head {
515 padding: var(--space-4) var(--space-5) var(--space-3);
516 border-bottom: 1px solid var(--border);
517 display: flex;
518 align-items: flex-start;
519 justify-content: space-between;
520 gap: var(--space-3);
521 flex-wrap: wrap;
522 }
523 .contrib-section-head-text { flex: 1; min-width: 240px; }
524 .contrib-section-title {
525 margin: 0;
526 font-family: var(--font-display);
527 font-size: 16px;
528 font-weight: 700;
529 letter-spacing: -0.018em;
530 color: var(--text-strong);
531 display: flex;
532 align-items: center;
533 gap: 10px;
534 }
535 .contrib-section-title-icon {
536 display: inline-flex;
537 align-items: center;
538 justify-content: center;
539 width: 26px; height: 26px;
540 border-radius: 8px;
6fd5915Claude541 background: rgba(91,110,232,0.12);
542 color: #5b6ee8;
543 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
17f6cacClaude544 flex-shrink: 0;
545 }
546 .contrib-section-sub {
547 margin: 6px 0 0 36px;
548 font-size: 12.5px;
549 color: var(--text-muted);
550 line-height: 1.45;
551 }
552 .contrib-section-body { padding: var(--space-4) var(--space-5); }
553
554 /* ─── Sparkline ─── */
555 .contrib-spark {
556 display: flex;
557 align-items: flex-end;
558 gap: 2px;
559 height: 80px;
560 padding: 4px 0;
561 }
562 .contrib-spark-bar {
563 flex: 1;
564 min-width: 0;
6fd5915Claude565 background: linear-gradient(180deg, #5b6ee8 0%, #5f8fa0 100%);
17f6cacClaude566 border-radius: 2px 2px 1px 1px;
567 transition: opacity 120ms ease, transform 120ms ease;
568 }
569 .contrib-spark-bar:hover {
570 opacity: 1 !important;
571 transform: scaleY(1.05);
572 transform-origin: bottom;
573 }
574 .contrib-spark-axis {
575 display: flex;
576 justify-content: space-between;
577 margin-top: 8px;
578 font-size: 11px;
579 color: var(--text-muted);
580 font-family: var(--font-mono);
581 letter-spacing: 0.04em;
582 }
583
584 /* ─── People grid ─── */
585 .contrib-grid {
586 display: grid;
587 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
588 gap: 12px;
589 }
590 .contrib-card {
591 display: flex;
592 align-items: flex-start;
593 gap: 14px;
594 padding: 14px;
595 background: rgba(255,255,255,0.018);
596 border: 1px solid var(--border);
597 border-radius: 12px;
598 transition: border-color 120ms ease, background 120ms ease;
599 }
600 .contrib-card:hover {
601 border-color: var(--border-strong);
602 background: rgba(255,255,255,0.03);
603 }
604 .contrib-rank {
605 flex-shrink: 0;
606 width: 30px;
607 text-align: right;
608 font-family: var(--font-mono);
609 font-size: 12px;
610 color: var(--text-muted);
611 font-weight: 600;
612 padding-top: 14px;
613 font-variant-numeric: tabular-nums;
614 }
615 .contrib-avatar {
616 width: 44px; height: 44px;
617 border-radius: 9999px;
6fd5915Claude618 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
17f6cacClaude619 color: #ffffff;
620 display: inline-flex;
621 align-items: center;
622 justify-content: center;
623 font-family: var(--font-display);
624 font-weight: 700;
625 font-size: 17px;
626 flex-shrink: 0;
627 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
628 }
629 .contrib-card-body { flex: 1; min-width: 0; }
630 .contrib-card-row {
631 display: flex;
632 align-items: baseline;
633 justify-content: space-between;
634 gap: 10px;
635 flex-wrap: wrap;
636 }
637 .contrib-card-name {
638 font-family: var(--font-display);
639 font-weight: 700;
640 font-size: 14.5px;
641 color: var(--text-strong);
642 text-decoration: none;
643 letter-spacing: -0.005em;
644 overflow-wrap: anywhere;
645 }
646 .contrib-card-name:hover { color: var(--text-strong); text-decoration: underline; }
647 .contrib-card-handle {
648 font-family: var(--font-mono);
649 font-size: 12px;
650 color: var(--text-muted);
651 overflow-wrap: anywhere;
652 }
653 .contrib-meta-row {
654 margin-top: 8px;
655 display: flex;
656 align-items: center;
657 gap: 8px;
658 flex-wrap: wrap;
659 font-size: 12px;
660 color: var(--text-muted);
661 }
662 .contrib-meta-row .sep { opacity: 0.4; }
663 .contrib-meta-label { color: var(--text-muted); }
664 .contrib-num {
665 font-variant-numeric: tabular-nums;
666 font-family: var(--font-mono);
667 font-size: 12px;
668 color: var(--text);
669 font-weight: 600;
670 }
671
672 /* ─── Role pills ─── */
673 .contrib-pill {
674 display: inline-flex;
675 align-items: center;
676 gap: 6px;
677 padding: 3px 9px;
678 border-radius: 9999px;
679 font-size: 11px;
680 font-weight: 600;
681 letter-spacing: 0.02em;
682 }
683 .contrib-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
684 .contrib-pill.is-maintainer {
6fd5915Claude685 background: rgba(91,110,232,0.16);
17f6cacClaude686 color: #c4b5fd;
6fd5915Claude687 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
17f6cacClaude688 }
689 .contrib-pill.is-contributor {
690 background: rgba(148,163,184,0.16);
691 color: #cbd5e1;
692 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
693 }
694
695 /* ─── Empty state ─── */
696 .contrib-empty {
697 position: relative;
698 overflow: hidden;
699 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
700 text-align: center;
701 background: var(--bg-elevated);
702 border: 1px dashed var(--border-strong);
703 border-radius: 16px;
704 }
705 .contrib-empty-orb {
706 position: absolute;
707 inset: -40% 30% auto 30%;
708 height: 280px;
6fd5915Claude709 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
17f6cacClaude710 filter: blur(70px);
711 opacity: 0.7;
712 pointer-events: none;
713 z-index: 0;
714 }
715 .contrib-empty-inner { position: relative; z-index: 1; }
716 .contrib-empty-icon {
717 width: 56px; height: 56px;
718 border-radius: 9999px;
6fd5915Claude719 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
720 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
17f6cacClaude721 display: inline-flex;
722 align-items: center;
723 justify-content: center;
724 color: #c4b5fd;
725 margin-bottom: 14px;
726 }
727 .contrib-empty-title {
728 font-family: var(--font-display);
729 font-size: 18px;
730 font-weight: 700;
731 margin: 0 0 6px;
732 color: var(--text-strong);
733 }
734 .contrib-empty-sub {
735 margin: 0 auto;
736 font-size: 13.5px;
737 color: var(--text-muted);
738 max-width: 480px;
739 line-height: 1.5;
740 }
741 .contrib-empty-sub code {
742 font-family: var(--font-mono);
743 font-size: 12.5px;
744 background: rgba(255,255,255,0.05);
745 border: 1px solid var(--border);
746 padding: 1px 6px;
747 border-radius: 6px;
748 color: var(--text);
749 }
750`;
751
43de941Claude752export default contributors;