Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame675 lines · 2 contributors
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";
f6730d0ccantynz-alt12import { RepoHeader, RepoNav, PageHeader, Badge, sharedComponentStyles } from "../views/components";
43de941Claude13import { 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">
f6730d0ccantynz-alt162 <PageHeader
163 eyebrow="Repository · Contributors"
164 title="Who built this."
165 lede={`Everyone with a commit on ${ref}, ranked by total commits. Bars below show weekly activity over the last year.`}
166 />
17f6cacClaude167
168 {/* ─── Stats strip ─── */}
169 {contribs.length > 0 && (
170 <div class="contrib-stats">
171 <div class="contrib-stat">
172 <div class="contrib-stat-value">{contribs.length.toLocaleString()}</div>
173 <div class="contrib-stat-label">Contributors</div>
174 </div>
175 <div class="contrib-stat">
176 <div class="contrib-stat-value">{totalCommits.toLocaleString()}</div>
177 <div class="contrib-stat-label">Total commits</div>
178 </div>
179 <div class="contrib-stat">
180 <div class="contrib-stat-value contrib-add">+{totalAdditions.toLocaleString()}</div>
181 <div class="contrib-stat-label">Lines added</div>
182 </div>
183 <div class="contrib-stat">
184 <div class="contrib-stat-value contrib-del">−{totalDeletions.toLocaleString()}</div>
185 <div class="contrib-stat-label">Lines removed</div>
186 </div>
187 </div>
188 )}
189
190 {contribs.length === 0 ? (
191 <div class="contrib-empty">
192 <div class="contrib-empty-orb" aria-hidden="true" />
193 <div class="contrib-empty-inner">
194 <div class="contrib-empty-icon" aria-hidden="true">
195 <IconCommit />
43de941Claude196 </div>
17f6cacClaude197 <h3 class="contrib-empty-title">Push your first commit to see contributors</h3>
198 <p class="contrib-empty-sub">
199 Once anyone pushes to <code>{ref}</code>, they'll appear here
200 ranked by commit count with a year of weekly activity.
201 </p>
43de941Claude202 </div>
17f6cacClaude203 </div>
204 ) : (
205 <>
206 {/* ─── Activity card ─── */}
207 <section class="contrib-section">
208 <header class="contrib-section-head">
209 <div class="contrib-section-head-text">
210 <h2 class="contrib-section-title">
211 <span class="contrib-section-title-icon" aria-hidden="true">
212 <IconBars />
213 </span>
214 Commit activity
215 </h2>
216 <p class="contrib-section-sub">
217 Weekly commit volume on <code class="contrib-ref-inline">{ref}</code> for the
218 last 52 weeks · <strong style="color:var(--text);font-variant-numeric:tabular-nums">{yearCommits.toLocaleString()}</strong> commits this year.
219 </p>
220 </div>
221 </header>
222 <div class="contrib-section-body">
223 <div class="contrib-spark" role="img" aria-label={`${yearCommits} commits in the last 52 weeks`}>
224 {weekCounts.map((count, i) => {
225 const ratio = count / maxWeek;
226 const heightPct = count === 0 ? 4 : Math.max(8, Math.round(ratio * 100));
227 const opacity = count === 0 ? 0.14 : Math.max(0.40, ratio).toFixed(2);
228 const weeksAgo = 51 - i;
229 return (
230 <div
231 class="contrib-spark-bar"
232 title={`${count} commit${count === 1 ? "" : "s"} · ${weeksAgo}w ago`}
233 style={`height:${heightPct}%;opacity:${opacity}`}
234 />
235 );
236 })}
237 </div>
238 <div class="contrib-spark-axis">
239 <span>52w ago</span>
240 <span>26w ago</span>
241 <span>now</span>
242 </div>
243 </div>
244 </section>
245
246 {/* ─── People section ─── */}
247 <section class="contrib-section">
248 <header class="contrib-section-head">
249 <div class="contrib-section-head-text">
250 <h2 class="contrib-section-title">
251 <span class="contrib-section-title-icon" aria-hidden="true">
252 <IconUsers />
253 </span>
254 Contributors
255 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
256 {" "}({contribs.length})
257 </span>
258 </h2>
259 <p class="contrib-section-sub">
260 Ranked by total commits. Click a name to open their profile.
261 </p>
262 </div>
263 </header>
264 <div class="contrib-section-body">
265 <div class="contrib-grid">
266 {contribs.map((ctb, idx) => {
267 const handle = handleFromEmail(ctb.email, ctb.name);
268 const initial = (ctb.name || ctb.email || "?")[0]?.toUpperCase() ?? "?";
269 const role = idx === 0 ? "Maintainer" : "Contributor";
f6730d0ccantynz-alt270 const roleVariant = idx === 0 ? "info" : "neutral";
17f6cacClaude271 return (
272 <div class="contrib-card">
273 <div class="contrib-rank" aria-hidden="true">
274 #{idx + 1}
275 </div>
276 <div class="contrib-avatar" aria-hidden="true">
277 {initial}
278 </div>
279 <div class="contrib-card-body">
280 <div class="contrib-card-row">
281 <a href={`/${handle}`} class="contrib-card-name">
282 {ctb.name}
283 </a>
f6730d0ccantynz-alt284 <Badge variant={roleVariant} dot>{role}</Badge>
17f6cacClaude285 </div>
286 <div class="contrib-card-handle">@{handle}</div>
287 <div class="contrib-meta-row">
288 <span class="contrib-num">{ctb.commits.toLocaleString()}</span>
289 <span class="contrib-meta-label">commit{ctb.commits === 1 ? "" : "s"}</span>
290 {(ctb.additions > 0 || ctb.deletions > 0) && (
291 <>
292 <span class="sep">·</span>
293 <span class="contrib-add contrib-num">+{ctb.additions.toLocaleString()}</span>
294 <span class="contrib-del contrib-num">−{ctb.deletions.toLocaleString()}</span>
295 </>
296 )}
297 {ctb.lastCommitAt && (
298 <>
299 <span class="sep">·</span>
300 <span class="contrib-num" title={ctb.lastCommitAt.toISOString()}>
301 {relativeTime(ctb.lastCommitAt)}
302 </span>
303 </>
304 )}
305 </div>
306 </div>
307 </div>
308 );
309 })}
310 </div>
311 </div>
312 </section>
313 </>
314 )}
315 </div>
f6730d0ccantynz-alt316 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
17f6cacClaude317 <style dangerouslySetInnerHTML={{ __html: contribStyles }} />
43de941Claude318 </Layout>
319 );
320});
321
17f6cacClaude322/** Derive a username-ish handle from an email — local part before "@",
323 * stripped of "+suffix" and any non-ident chars. Falls back to the author
324 * name lowercased if the email is unhelpful. */
325function handleFromEmail(email: string, fallbackName: string): string {
326 if (!email) return slugify(fallbackName);
327 const local = email.split("@")[0] || "";
328 const stripped = local.split("+")[0]!.replace(/[^a-zA-Z0-9_-]/g, "");
329 if (stripped) return stripped.toLowerCase();
330 return slugify(fallbackName);
331}
332
333function slugify(s: string): string {
334 return (s || "anon").toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "anon";
335}
336
337function relativeTime(d: Date): string {
338 const diff = Date.now() - d.getTime();
339 const s = Math.max(0, Math.floor(diff / 1000));
340 if (s < 60) return "just now";
341 const m = Math.floor(s / 60);
342 if (m < 60) return `${m}m ago`;
343 const h = Math.floor(m / 60);
344 if (h < 24) return `${h}h ago`;
345 const days = Math.floor(h / 24);
346 if (days < 30) return `${days}d ago`;
347 const months = Math.floor(days / 30);
348 if (months < 12) return `${months}mo ago`;
349 const years = Math.floor(days / 365);
350 return `${years}y ago`;
351}
352
353function IconUsers() {
354 return (
355 <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">
356 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
357 <circle cx="9" cy="7" r="4" />
358 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
359 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
360 </svg>
361 );
362}
363function IconBars() {
364 return (
365 <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">
366 <line x1="12" y1="20" x2="12" y2="10" />
367 <line x1="18" y1="20" x2="18" y2="4" />
368 <line x1="6" y1="20" x2="6" y2="16" />
369 </svg>
370 );
371}
372function IconCommit() {
373 return (
374 <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">
375 <circle cx="12" cy="12" r="3" />
376 <line x1="3" y1="12" x2="9" y2="12" />
377 <line x1="15" y1="12" x2="21" y2="12" />
378 </svg>
379 );
380}
381
382// ─── Scoped CSS (.contrib-*) ────────────────────────────────────────────────
383
384const contribStyles = `
eed4684Claude385 .contrib-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
17f6cacClaude386
f6730d0ccantynz-alt387 /* ─── Inline ref chip (activity section) ─── */
17f6cacClaude388 .contrib-ref-inline {
389 font-family: var(--font-mono);
390 font-size: 12.5px;
391 background: rgba(255,255,255,0.04);
392 border: 1px solid var(--border);
393 padding: 1px 7px;
394 border-radius: 6px;
395 color: var(--text);
396 }
397
398 /* ─── Stats strip ─── */
399 .contrib-stats {
400 display: grid;
401 grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
402 gap: 10px;
403 margin-bottom: var(--space-5);
404 }
405 .contrib-stat {
406 padding: 14px 16px;
407 background: var(--bg-elevated);
408 border: 1px solid var(--border);
409 border-radius: 12px;
410 position: relative;
411 overflow: hidden;
412 }
413 .contrib-stat::before {
414 content: '';
415 position: absolute;
416 top: 0; left: 0; right: 0;
417 height: 1px;
6fd5915Claude418 background: linear-gradient(90deg, transparent, rgba(91,110,232,0.40), rgba(95,143,160,0.30), transparent);
17f6cacClaude419 opacity: 0.55;
420 pointer-events: none;
421 }
422 .contrib-stat-value {
423 font-family: var(--font-display);
424 font-size: 22px;
425 font-weight: 800;
426 color: var(--text-strong);
427 letter-spacing: -0.018em;
428 font-variant-numeric: tabular-nums;
429 line-height: 1.1;
430 }
431 .contrib-stat-label {
432 margin-top: 2px;
433 font-size: 11.5px;
434 color: var(--text-muted);
435 text-transform: uppercase;
436 letter-spacing: 0.06em;
437 font-weight: 600;
438 }
e589f77ccantynz-alt439 .contrib-add { color: var(--green); }
440 .contrib-del { color: var(--red); }
17f6cacClaude441
442 /* ─── Section cards ─── */
443 .contrib-section {
444 margin-bottom: var(--space-5);
445 background: var(--bg-elevated);
446 border: 1px solid var(--border);
447 border-radius: 14px;
448 overflow: hidden;
449 position: relative;
450 }
451 .contrib-section::before {
452 content: '';
453 position: absolute;
454 top: 0; left: 0; right: 0;
455 height: 2px;
6fd5915Claude456 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
17f6cacClaude457 opacity: 0.55;
458 pointer-events: none;
459 }
460 .contrib-section-head {
461 padding: var(--space-4) var(--space-5) var(--space-3);
462 border-bottom: 1px solid var(--border);
463 display: flex;
464 align-items: flex-start;
465 justify-content: space-between;
466 gap: var(--space-3);
467 flex-wrap: wrap;
468 }
469 .contrib-section-head-text { flex: 1; min-width: 240px; }
470 .contrib-section-title {
471 margin: 0;
472 font-family: var(--font-display);
473 font-size: 16px;
474 font-weight: 700;
475 letter-spacing: -0.018em;
476 color: var(--text-strong);
477 display: flex;
478 align-items: center;
479 gap: 10px;
480 }
481 .contrib-section-title-icon {
482 display: inline-flex;
483 align-items: center;
484 justify-content: center;
485 width: 26px; height: 26px;
486 border-radius: 8px;
6fd5915Claude487 background: rgba(91,110,232,0.12);
e589f77ccantynz-alt488 color: var(--accent);
6fd5915Claude489 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
17f6cacClaude490 flex-shrink: 0;
491 }
492 .contrib-section-sub {
493 margin: 6px 0 0 36px;
494 font-size: 12.5px;
495 color: var(--text-muted);
496 line-height: 1.45;
497 }
498 .contrib-section-body { padding: var(--space-4) var(--space-5); }
499
500 /* ─── Sparkline ─── */
501 .contrib-spark {
502 display: flex;
503 align-items: flex-end;
504 gap: 2px;
505 height: 80px;
506 padding: 4px 0;
507 }
508 .contrib-spark-bar {
509 flex: 1;
510 min-width: 0;
6fd5915Claude511 background: linear-gradient(180deg, #5b6ee8 0%, #5f8fa0 100%);
17f6cacClaude512 border-radius: 2px 2px 1px 1px;
513 transition: opacity 120ms ease, transform 120ms ease;
514 }
515 .contrib-spark-bar:hover {
516 opacity: 1 !important;
517 transform: scaleY(1.05);
518 transform-origin: bottom;
519 }
520 .contrib-spark-axis {
521 display: flex;
522 justify-content: space-between;
523 margin-top: 8px;
524 font-size: 11px;
525 color: var(--text-muted);
526 font-family: var(--font-mono);
527 letter-spacing: 0.04em;
528 }
529
530 /* ─── People grid ─── */
531 .contrib-grid {
532 display: grid;
533 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
534 gap: 12px;
535 }
536 .contrib-card {
537 display: flex;
538 align-items: flex-start;
539 gap: 14px;
540 padding: 14px;
541 background: rgba(255,255,255,0.018);
542 border: 1px solid var(--border);
543 border-radius: 12px;
544 transition: border-color 120ms ease, background 120ms ease;
545 }
546 .contrib-card:hover {
547 border-color: var(--border-strong);
548 background: rgba(255,255,255,0.03);
549 }
550 .contrib-rank {
551 flex-shrink: 0;
552 width: 30px;
553 text-align: right;
554 font-family: var(--font-mono);
555 font-size: 12px;
556 color: var(--text-muted);
557 font-weight: 600;
558 padding-top: 14px;
559 font-variant-numeric: tabular-nums;
560 }
561 .contrib-avatar {
562 width: 44px; height: 44px;
563 border-radius: 9999px;
6fd5915Claude564 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
17f6cacClaude565 color: #ffffff;
566 display: inline-flex;
567 align-items: center;
568 justify-content: center;
569 font-family: var(--font-display);
570 font-weight: 700;
571 font-size: 17px;
572 flex-shrink: 0;
573 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
574 }
575 .contrib-card-body { flex: 1; min-width: 0; }
576 .contrib-card-row {
577 display: flex;
578 align-items: baseline;
579 justify-content: space-between;
580 gap: 10px;
581 flex-wrap: wrap;
582 }
583 .contrib-card-name {
584 font-family: var(--font-display);
585 font-weight: 700;
586 font-size: 14.5px;
587 color: var(--text-strong);
588 text-decoration: none;
589 letter-spacing: -0.005em;
590 overflow-wrap: anywhere;
591 }
592 .contrib-card-name:hover { color: var(--text-strong); text-decoration: underline; }
593 .contrib-card-handle {
594 font-family: var(--font-mono);
595 font-size: 12px;
596 color: var(--text-muted);
597 overflow-wrap: anywhere;
598 }
599 .contrib-meta-row {
600 margin-top: 8px;
601 display: flex;
602 align-items: center;
603 gap: 8px;
604 flex-wrap: wrap;
605 font-size: 12px;
606 color: var(--text-muted);
607 }
608 .contrib-meta-row .sep { opacity: 0.4; }
609 .contrib-meta-label { color: var(--text-muted); }
610 .contrib-num {
611 font-variant-numeric: tabular-nums;
612 font-family: var(--font-mono);
613 font-size: 12px;
614 color: var(--text);
615 font-weight: 600;
616 }
617
618 /* ─── Empty state ─── */
619 .contrib-empty {
620 position: relative;
621 overflow: hidden;
622 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
623 text-align: center;
624 background: var(--bg-elevated);
625 border: 1px dashed var(--border-strong);
626 border-radius: 16px;
627 }
628 .contrib-empty-orb {
629 position: absolute;
630 inset: -40% 30% auto 30%;
631 height: 280px;
6fd5915Claude632 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
17f6cacClaude633 filter: blur(70px);
634 opacity: 0.7;
635 pointer-events: none;
636 z-index: 0;
637 }
638 .contrib-empty-inner { position: relative; z-index: 1; }
639 .contrib-empty-icon {
640 width: 56px; height: 56px;
641 border-radius: 9999px;
6fd5915Claude642 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
643 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
17f6cacClaude644 display: inline-flex;
645 align-items: center;
646 justify-content: center;
647 color: #c4b5fd;
648 margin-bottom: 14px;
649 }
650 .contrib-empty-title {
651 font-family: var(--font-display);
652 font-size: 18px;
653 font-weight: 700;
654 margin: 0 0 6px;
655 color: var(--text-strong);
656 }
657 .contrib-empty-sub {
658 margin: 0 auto;
659 font-size: 13.5px;
660 color: var(--text-muted);
661 max-width: 480px;
662 line-height: 1.5;
663 }
664 .contrib-empty-sub code {
665 font-family: var(--font-mono);
666 font-size: 12.5px;
667 background: rgba(255,255,255,0.05);
668 border: 1px solid var(--border);
669 padding: 1px 6px;
670 border-radius: 6px;
671 color: var(--text);
672 }
673`;
674
43de941Claude675export default contributors;