Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit17f6cacunknown_key

polish(repo): 2026 cards + headers for collaborators + contributors tabs

Claude committed on May 24, 2026Parent: b1f2895
2 files changed+132314617f6cace2b4d93cdf4966f7c3bca015264a2ffd1
2 changed files+1323−146
Modifiedsrc/routes/collaborators.tsx+668−96View fileUnifiedSplit
1414 * Middleware: softAuth on all, plus an inline owner-only check that mirrors
1515 * `src/routes/repo-settings.tsx` — the owner of the repo (by username) must
1616 * match the authed user. Non-owners get 403.
17 *
18 * 2026 polish: the GET surface uses a scoped `.collab-*` class system that
19 * mirrors `admin-ops.tsx` (section cards, hero gradient hairline, traffic-
20 * light dots for invite status). RepoHeader sits above untouched; we only
21 * own the content-area markup beneath it.
1722 */
1823
1924import { Hono } from "hono";
3035import type { AuthEnv } from "../middleware/auth";
3136import { generateInviteToken, hashInviteToken } from "../lib/invite-tokens";
3237import { sendEmail, absoluteUrl } from "../lib/email";
33import {
34 Container,
35 Form,
36 FormGroup,
37 Input,
38 Select,
39 Button,
40 Alert,
41 EmptyState,
42} from "../views/ui";
38import { EmptyState } from "../views/ui";
4339
4440const collaboratorRoutes = new Hono<AuthEnv>();
4541
8682 return { owner, repo, user };
8783}
8884
85// ─── Scoped CSS (.collab-*) ─────────────────────────────────────────────────
86//
87// Every selector is prefixed `.collab-*` so the surface can't bleed into
88// the repo header / nav / page chrome above. Tokens reused from the layout
89// (--bg-elevated, --border, --text-strong, --accent, --space-*, --font-*).
90
91const collabStyles = `
92 .collab-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
93
94 /* ─── Header strip (sits below RepoHeader + RepoNav) ─── */
95 .collab-head { margin-bottom: var(--space-5); }
96 .collab-eyebrow {
97 display: inline-flex;
98 align-items: center;
99 gap: 8px;
100 text-transform: uppercase;
101 font-family: var(--font-mono);
102 font-size: 11px;
103 letter-spacing: 0.16em;
104 color: var(--text-muted);
105 font-weight: 600;
106 margin-bottom: 10px;
107 }
108 .collab-eyebrow-dot {
109 width: 8px; height: 8px;
110 border-radius: 9999px;
111 background: linear-gradient(135deg, #8c6dff, #36c5d6);
112 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
113 }
114 .collab-title {
115 font-family: var(--font-display);
116 font-size: clamp(24px, 3.4vw, 36px);
117 font-weight: 800;
118 letter-spacing: -0.028em;
119 line-height: 1.1;
120 margin: 0 0 6px;
121 color: var(--text-strong);
122 }
123 .collab-title-grad {
124 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
125 -webkit-background-clip: text;
126 background-clip: text;
127 -webkit-text-fill-color: transparent;
128 color: transparent;
129 }
130 .collab-sub {
131 margin: 0;
132 font-size: 14px;
133 color: var(--text-muted);
134 line-height: 1.5;
135 max-width: 700px;
136 }
137
138 /* ─── Banners ─── */
139 .collab-banner {
140 margin-bottom: var(--space-4);
141 padding: 10px 14px;
142 border-radius: 10px;
143 font-size: 13.5px;
144 border: 1px solid var(--border);
145 background: rgba(255,255,255,0.025);
146 color: var(--text);
147 display: flex;
148 align-items: center;
149 gap: 10px;
150 }
151 .collab-banner.is-ok {
152 border-color: rgba(52,211,153,0.40);
153 background: rgba(52,211,153,0.08);
154 color: #bbf7d0;
155 }
156 .collab-banner.is-error {
157 border-color: rgba(248,113,113,0.40);
158 background: rgba(248,113,113,0.08);
159 color: #fecaca;
160 }
161 .collab-banner-dot {
162 width: 8px; height: 8px;
163 border-radius: 9999px;
164 background: currentColor;
165 flex-shrink: 0;
166 }
167
168 /* ─── Section cards ─── */
169 .collab-section {
170 margin-bottom: var(--space-5);
171 background: var(--bg-elevated);
172 border: 1px solid var(--border);
173 border-radius: 14px;
174 overflow: hidden;
175 position: relative;
176 }
177 .collab-section::before {
178 content: '';
179 position: absolute;
180 top: 0; left: 0; right: 0;
181 height: 2px;
182 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
183 opacity: 0.55;
184 pointer-events: none;
185 }
186 .collab-section-head {
187 padding: var(--space-4) var(--space-5) var(--space-3);
188 border-bottom: 1px solid var(--border);
189 display: flex;
190 align-items: flex-start;
191 justify-content: space-between;
192 gap: var(--space-3);
193 flex-wrap: wrap;
194 }
195 .collab-section-head-text { flex: 1; min-width: 240px; }
196 .collab-section-title {
197 margin: 0;
198 font-family: var(--font-display);
199 font-size: 16px;
200 font-weight: 700;
201 letter-spacing: -0.018em;
202 color: var(--text-strong);
203 display: flex;
204 align-items: center;
205 gap: 10px;
206 }
207 .collab-section-title-icon {
208 display: inline-flex;
209 align-items: center;
210 justify-content: center;
211 width: 26px; height: 26px;
212 border-radius: 8px;
213 background: rgba(140,109,255,0.12);
214 color: #b69dff;
215 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
216 flex-shrink: 0;
217 }
218 .collab-section-sub {
219 margin: 6px 0 0 36px;
220 font-size: 12.5px;
221 color: var(--text-muted);
222 line-height: 1.45;
223 }
224 .collab-section-body { padding: var(--space-4) var(--space-5); }
225
226 /* ─── Invite form ─── */
227 .collab-invite {
228 display: grid;
229 grid-template-columns: 1.4fr 1fr auto;
230 gap: 10px;
231 align-items: end;
232 }
233 @media (max-width: 700px) {
234 .collab-invite { grid-template-columns: 1fr; }
235 }
236 .collab-field-label {
237 display: block;
238 font-size: 11.5px;
239 color: var(--text-muted);
240 font-weight: 600;
241 text-transform: uppercase;
242 letter-spacing: 0.06em;
243 margin-bottom: 6px;
244 }
245 .collab-input,
246 .collab-select {
247 width: 100%;
248 box-sizing: border-box;
249 padding: 9px 12px;
250 font: inherit;
251 font-size: 13.5px;
252 color: var(--text);
253 background: rgba(255,255,255,0.03);
254 border: 1px solid var(--border-strong);
255 border-radius: 10px;
256 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
257 }
258 .collab-input:focus,
259 .collab-select:focus {
260 outline: none;
261 border-color: rgba(140,109,255,0.55);
262 background: rgba(255,255,255,0.05);
263 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
264 }
265 .collab-select { appearance: none; padding-right: 28px; background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%); background-position: right 12px top 50%, right 7px top 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; }
266
267 /* ─── Buttons ─── */
268 .collab-btn {
269 display: inline-flex;
270 align-items: center;
271 justify-content: center;
272 gap: 6px;
273 padding: 9px 16px;
274 border-radius: 10px;
275 font-size: 13px;
276 font-weight: 600;
277 text-decoration: none;
278 border: 1px solid transparent;
279 cursor: pointer;
280 font: inherit;
281 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
282 line-height: 1;
283 white-space: nowrap;
284 }
285 .collab-btn-primary {
286 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
287 color: #ffffff;
288 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
289 }
290 .collab-btn-primary:hover {
291 transform: translateY(-1px);
292 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
293 text-decoration: none;
294 color: #ffffff;
295 }
296 .collab-btn-ghost {
297 background: transparent;
298 color: var(--text);
299 border-color: var(--border-strong);
300 }
301 .collab-btn-ghost:hover {
302 background: rgba(140,109,255,0.06);
303 border-color: rgba(140,109,255,0.45);
304 color: var(--text-strong);
305 text-decoration: none;
306 }
307 .collab-btn-danger {
308 background: transparent;
309 color: #fca5a5;
310 border-color: rgba(248,113,113,0.35);
311 }
312 .collab-btn-danger:hover {
313 border-style: dashed;
314 border-color: rgba(248,113,113,0.70);
315 background: rgba(248,113,113,0.06);
316 color: #fecaca;
317 text-decoration: none;
318 }
319
320 /* ─── Crumb links above title ─── */
321 .collab-crumbs {
322 display: flex;
323 align-items: center;
324 gap: 12px;
325 flex-wrap: wrap;
326 margin-bottom: var(--space-4);
327 font-size: 12.5px;
328 color: var(--text-muted);
329 }
330 .collab-crumbs a {
331 display: inline-flex;
332 align-items: center;
333 gap: 5px;
334 padding: 6px 11px;
335 background: rgba(255,255,255,0.025);
336 border: 1px solid var(--border);
337 border-radius: 8px;
338 color: var(--text-muted);
339 text-decoration: none;
340 font-weight: 500;
341 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
342 }
343 .collab-crumbs a:hover {
344 border-color: var(--border-strong);
345 color: var(--text-strong);
346 background: rgba(255,255,255,0.04);
347 text-decoration: none;
348 }
349
350 /* ─── People grid ─── */
351 .collab-grid {
352 display: grid;
353 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
354 gap: 12px;
355 }
356 .collab-card {
357 display: flex;
358 align-items: flex-start;
359 gap: 14px;
360 padding: 14px;
361 background: rgba(255,255,255,0.018);
362 border: 1px solid var(--border);
363 border-radius: 12px;
364 transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
365 }
366 .collab-card:hover {
367 border-color: var(--border-strong);
368 background: rgba(255,255,255,0.03);
369 }
370 .collab-avatar {
371 width: 44px; height: 44px;
372 border-radius: 9999px;
373 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
374 color: #ffffff;
375 display: inline-flex;
376 align-items: center;
377 justify-content: center;
378 font-family: var(--font-display);
379 font-weight: 700;
380 font-size: 17px;
381 flex-shrink: 0;
382 overflow: hidden;
383 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
384 }
385 .collab-avatar img {
386 width: 100%; height: 100%;
387 object-fit: cover;
388 display: block;
389 }
390 .collab-card-body { flex: 1; min-width: 0; }
391 .collab-card-row {
392 display: flex;
393 align-items: baseline;
394 justify-content: space-between;
395 gap: 10px;
396 flex-wrap: wrap;
397 }
398 .collab-card-name {
399 font-family: var(--font-display);
400 font-weight: 700;
401 font-size: 14.5px;
402 color: var(--text-strong);
403 text-decoration: none;
404 letter-spacing: -0.005em;
405 }
406 .collab-card-name:hover { color: var(--text-strong); text-decoration: underline; }
407 .collab-card-handle {
408 font-family: var(--font-mono);
409 font-size: 12px;
410 color: var(--text-muted);
411 }
412 .collab-meta-row {
413 margin-top: 8px;
414 display: flex;
415 align-items: center;
416 gap: 8px;
417 flex-wrap: wrap;
418 font-size: 12px;
419 color: var(--text-muted);
420 font-variant-numeric: tabular-nums;
421 }
422 .collab-meta-row .sep { opacity: 0.4; }
423
424 /* ─── Role pills ─── */
425 .collab-pill {
426 display: inline-flex;
427 align-items: center;
428 gap: 6px;
429 padding: 3px 9px;
430 border-radius: 9999px;
431 font-size: 11px;
432 font-weight: 600;
433 letter-spacing: 0.02em;
434 text-transform: capitalize;
435 }
436 .collab-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
437 .collab-pill.is-admin {
438 background: rgba(140,109,255,0.16);
439 color: #c4b5fd;
440 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
441 }
442 .collab-pill.is-write {
443 background: rgba(54,197,214,0.14);
444 color: #67e8f9;
445 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32);
446 }
447 .collab-pill.is-read {
448 background: rgba(148,163,184,0.16);
449 color: #cbd5e1;
450 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
451 }
452 .collab-pill.is-pending {
453 background: rgba(251,191,36,0.12);
454 color: #fde68a;
455 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
456 }
457 .collab-pill.is-accepted {
458 background: rgba(52,211,153,0.14);
459 color: #6ee7b7;
460 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
461 }
462
463 /* ─── Card action ─── */
464 .collab-card-actions { margin-top: 12px; }
465 .collab-card-actions form { margin: 0; }
466
467 /* ─── Empty state ─── */
468 .collab-empty {
469 position: relative;
470 overflow: hidden;
471 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
472 text-align: center;
473 background: var(--bg-elevated);
474 border: 1px dashed var(--border-strong);
475 border-radius: 16px;
476 }
477 .collab-empty-orb {
478 position: absolute;
479 inset: -40% 30% auto 30%;
480 height: 280px;
481 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
482 filter: blur(70px);
483 opacity: 0.7;
484 pointer-events: none;
485 z-index: 0;
486 }
487 .collab-empty-inner { position: relative; z-index: 1; }
488 .collab-empty-icon {
489 width: 56px; height: 56px;
490 border-radius: 9999px;
491 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
492 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
493 display: inline-flex;
494 align-items: center;
495 justify-content: center;
496 color: #c4b5fd;
497 margin-bottom: 14px;
498 }
499 .collab-empty-title {
500 font-family: var(--font-display);
501 font-size: 18px;
502 font-weight: 700;
503 margin: 0 0 6px;
504 color: var(--text-strong);
505 }
506 .collab-empty-sub {
507 margin: 0 auto 0;
508 font-size: 13.5px;
509 color: var(--text-muted);
510 max-width: 420px;
511 line-height: 1.5;
512 }
513`;
514
515function IconUsers() {
516 return (
517 <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">
518 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
519 <circle cx="9" cy="7" r="4" />
520 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
521 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
522 </svg>
523 );
524}
525function IconPlus() {
526 return (
527 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
528 <line x1="12" y1="5" x2="12" y2="19" />
529 <line x1="5" y1="12" x2="19" y2="12" />
530 </svg>
531 );
532}
533function IconArrowLeft() {
534 return (
535 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
536 <line x1="19" y1="12" x2="5" y2="12" />
537 <polyline points="12 19 5 12 12 5" />
538 </svg>
539 );
540}
541function IconArrowRight() {
542 return (
543 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
544 <line x1="5" y1="12" x2="19" y2="12" />
545 <polyline points="12 5 19 12 12 19" />
546 </svg>
547 );
548}
549
550type CollabRole = "read" | "write" | "admin";
551
552function rolePillClass(role: string): string {
553 if (role === "admin") return "collab-pill is-admin";
554 if (role === "write") return "collab-pill is-write";
555 return "collab-pill is-read";
556}
557
558function roleLabel(role: string): string {
559 if (role === "admin") return "Admin";
560 if (role === "write") return "Write";
561 return "Read";
562}
563
564/** ISO-style short date with tabular-nums-friendly format. */
565function shortDate(d: Date | string | null | undefined): string {
566 if (!d) return "—";
567 const dt = d instanceof Date ? d : new Date(d);
568 if (Number.isNaN(dt.getTime())) return "—";
569 return dt.toISOString().slice(0, 10);
570}
571
89572// ─── List collaborators ─────────────────────────────────────────────────────
90573
91574collaboratorRoutes.get(
118601 return c.html(
119602 <Layout title={`Collaborators — ${ownerName}/${repoName}`} user={user}>
120603 <RepoHeader owner={ownerName} repo={repoName} />
121 <Container maxWidth={700}>
122 <h2 style="margin-bottom: 16px">Collaborators</h2>
123 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
124 <a href={`/${ownerName}/${repoName}/settings`}>← Back to settings</a>
125 {" | "}
126 <a
127 href={`/${ownerName}/${repoName}/settings/collaborators/teams`}
128 >
129 Invite a team →
604 <div class="collab-wrap">
605 <div class="collab-crumbs">
606 <a href={`/${ownerName}/${repoName}/settings`}>
607 <IconArrowLeft />
608 Back to settings
609 </a>
610 <a href={`/${ownerName}/${repoName}/settings/collaborators/teams`}>
611 Invite a team
612 <IconArrowRight />
130613 </a>
131 </p>
614 </div>
615
616 <header class="collab-head">
617 <div class="collab-eyebrow">
618 <span class="collab-eyebrow-dot" aria-hidden="true" />
619 Repository · Collaborators
620 </div>
621 <h1 class="collab-title">
622 <span class="collab-title-grad">People with access.</span>
623 </h1>
624 <p class="collab-sub">
625 Owners can grant read, write, or admin scopes. Invitees confirm via
626 email before they appear as active.
627 </p>
628 </header>
629
132630 {success && (
133 <Alert variant="success">{decodeURIComponent(success)}</Alert>
631 <div class="collab-banner is-ok" role="status">
632 <span class="collab-banner-dot" aria-hidden="true" />
633 {decodeURIComponent(success)}
634 </div>
635 )}
636 {error && (
637 <div class="collab-banner is-error" role="alert">
638 <span class="collab-banner-dot" aria-hidden="true" />
639 {decodeURIComponent(error)}
640 </div>
134641 )}
135 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
136
137 <div
138 style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
139 >
140 <h3 style="margin-bottom: 12px">Add a collaborator</h3>
141 <Form
142 method="post"
143 action={`/${ownerName}/${repoName}/settings/collaborators/add`}
144 >
145 <FormGroup label="Username" htmlFor="username">
146 <Input
147 name="username"
148 id="username"
149 placeholder="github-username"
150 required
151 />
152 </FormGroup>
153 <FormGroup label="Role" htmlFor="role">
154 <Select name="role" id="role" value="read">
155 <option value="read">Read — clone + pull</option>
156 <option value="write">Write — push + merge</option>
157 <option value="admin">Admin — full control</option>
158 </Select>
159 </FormGroup>
160 <Button type="submit" variant="primary">
161 Add collaborator
162 </Button>
163 </Form>
164 </div>
165642
166 {rows.length === 0 ? (
167 <EmptyState title="No collaborators yet">
168 <p>
169 Add a collaborator above to grant them access to this
170 repository.
171 </p>
172 </EmptyState>
173 ) : (
174 <div>
175 {rows.map((row) => (
176 <div class="ssh-key-item">
177 <div>
178 <strong>
179 {row.avatarUrl && (
180 <img
181 src={row.avatarUrl}
182 alt=""
183 width={20}
184 height={20}
185 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
186 />
187 )}
188 <a href={`/${row.username}`}>{row.username}</a>
189 </strong>
190 <div class="ssh-key-meta">
191 Role: <strong>{row.role}</strong> | Invited:{" "}
192 {new Date(row.invitedAt).toLocaleDateString()} |{" "}
193 {row.acceptedAt ? (
194 <span style="color: var(--green)">Accepted</span>
195 ) : (
196 <span style="color: var(--yellow)">Pending</span>
197 )}
643 {/* ─── Invite section ─── */}
644 <section class="collab-section">
645 <header class="collab-section-head">
646 <div class="collab-section-head-text">
647 <h2 class="collab-section-title">
648 <span class="collab-section-title-icon" aria-hidden="true">
649 <IconPlus />
650 </span>
651 Invite a collaborator
652 </h2>
653 <p class="collab-section-sub">
654 Enter a Gluecron username. They'll receive an email with a
655 one-time invite link.
656 </p>
657 </div>
658 </header>
659 <div class="collab-section-body">
660 <form
661 method="post"
662 action={`/${ownerName}/${repoName}/settings/collaborators/add`}
663 class="collab-invite"
664 >
665 <div>
666 <label class="collab-field-label" for="collab-username">Username</label>
667 <input
668 class="collab-input"
669 name="username"
670 id="collab-username"
671 placeholder="github-username"
672 required
673 />
674 </div>
675 <div>
676 <label class="collab-field-label" for="collab-role">Role</label>
677 <select class="collab-select" name="role" id="collab-role">
678 <option value="read">Read — clone + pull</option>
679 <option value="write">Write — push + merge</option>
680 <option value="admin">Admin — full control</option>
681 </select>
682 </div>
683 <div>
684 <button type="submit" class="collab-btn collab-btn-primary">
685 <IconPlus />
686 Send invite
687 </button>
688 </div>
689 </form>
690 </div>
691 </section>
692
693 {/* ─── People section ─── */}
694 <section class="collab-section">
695 <header class="collab-section-head">
696 <div class="collab-section-head-text">
697 <h2 class="collab-section-title">
698 <span class="collab-section-title-icon" aria-hidden="true">
699 <IconUsers />
700 </span>
701 Active &amp; pending
702 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
703 {" "}({rows.length})
704 </span>
705 </h2>
706 <p class="collab-section-sub">
707 Click a name to view their profile. Removing a collaborator
708 revokes access immediately.
709 </p>
710 </div>
711 </header>
712 <div class="collab-section-body">
713 {rows.length === 0 ? (
714 <div class="collab-empty">
715 <div class="collab-empty-orb" aria-hidden="true" />
716 <div class="collab-empty-inner">
717 <div class="collab-empty-icon" aria-hidden="true">
718 <IconUsers />
198719 </div>
720 <h3 class="collab-empty-title">Invite your first collaborator</h3>
721 <p class="collab-empty-sub">
722 Add a teammate above to grant them clone, push, or admin
723 access to this repository.
724 </p>
199725 </div>
200 <form
201 method="post"
202 action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`}
203 onsubmit="return confirm('Remove this collaborator?')"
204 >
205 <Button type="submit" variant="danger" size="sm">
206 Remove
207 </Button>
208 </form>
209726 </div>
210 ))}
727 ) : (
728 <div class="collab-grid">
729 {rows.map((row) => {
730 const accepted = !!row.acceptedAt;
731 return (
732 <div class="collab-card">
733 <div class="collab-avatar" aria-hidden="true">
734 {row.avatarUrl ? (
735 <img src={row.avatarUrl} alt="" loading="lazy" />
736 ) : (
737 row.username[0]?.toUpperCase() ?? "?"
738 )}
739 </div>
740 <div class="collab-card-body">
741 <div class="collab-card-row">
742 <a href={`/${row.username}`} class="collab-card-name">
743 {row.username}
744 </a>
745 <span class={rolePillClass(row.role)}>
746 <span class="dot" aria-hidden="true" />
747 {roleLabel(row.role)}
748 </span>
749 </div>
750 <div class="collab-card-handle">@{row.username}</div>
751 <div class="collab-meta-row">
752 <span class={accepted ? "collab-pill is-accepted" : "collab-pill is-pending"}>
753 <span class="dot" aria-hidden="true" />
754 {accepted ? "Accepted" : "Pending"}
755 </span>
756 <span class="sep">·</span>
757 <span>Invited {shortDate(row.invitedAt)}</span>
758 {accepted && row.acceptedAt && (
759 <>
760 <span class="sep">·</span>
761 <span>Joined {shortDate(row.acceptedAt)}</span>
762 </>
763 )}
764 </div>
765 <div class="collab-card-actions">
766 <form
767 method="post"
768 action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`}
769 onsubmit="return confirm('Remove this collaborator?')"
770 >
771 <button type="submit" class="collab-btn collab-btn-danger">
772 Remove access
773 </button>
774 </form>
775 </div>
776 </div>
777 </div>
778 );
779 })}
780 </div>
781 )}
211782 </div>
212 )}
213 </Container>
783 </section>
784 </div>
785 <style dangerouslySetInnerHTML={{ __html: collabStyles }} />
214786 </Layout>
215787 );
216788 }
231803
232804 const username = String(body.username || "").trim();
233805 const roleRaw = String(body.role || "read");
234 const role: "read" | "write" | "admin" =
806 const role: CollabRole =
235807 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
236808
237809 if (!username) {
Modifiedsrc/routes/contributors.tsx+655−50View fileUnifiedSplit
11/**
22 * Contributors page — who contributed to this repo, commit counts.
3 *
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.
38 */
49
510import { Hono } from "hono";
813import { getRepoPath, repoExists, getDefaultBranch } from "../git/repository";
914import { softAuth } from "../middleware/auth";
1015import type { AuthEnv } from "../middleware/auth";
11import {
12 Avatar,
13 Card,
14 Flex,
15 List,
16 ListItem,
17 PageHeader,
18 Text,
19 Tooltip,
20} from "../views/ui";
2116
2217const contributors = new Hono<AuthEnv>();
2318
2924 commits: number;
3025 additions: number;
3126 deletions: number;
27 lastCommitAt: Date | null;
3228}
3329
3430contributors.get("/:owner/:repo/contributors", async (c) => {
6157 commits: parseInt(match[1], 10),
6258 additions: 0,
6359 deletions: 0,
64 };
60 lastCommitAt: null,
61 } as Contributor;
6562 })
6663 .filter((c): c is Contributor => c !== null)
6764 .sort((a, b) => b.commits - a.commits);
6865
66 // 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
69124 // Get recent commit activity (last 52 weeks)
70125 const activityProc = Bun.spawn(
71126 [
94149 }
95150
96151 const maxWeek = Math.max(...weekCounts, 1);
152 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);
97156
98157 return c.html(
99158 <Layout title={`Contributors — ${owner}/${repo}`} user={user}>
100159 <RepoHeader owner={owner} repo={repo} />
101160 <RepoNav owner={owner} repo={repo} active="code" />
102 <PageHeader title="Contributors" />
103
104 <Card style="margin-bottom:24px;padding:16px">
105 <Text size={13} muted>Commit activity — last year</Text>
106 <Flex gap={2} align="flex-end" style="height:60px;margin-top:8px">
107 {weekCounts.map((count) => (
108 <Tooltip text={`${count} commits`}>
109 <div
110 style={`flex:1;background:var(--green);opacity:${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)};height:${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"};border-radius:1px;`}
111 />
112 </Tooltip>
113 ))}
114 </Flex>
115 </Card>
116
117 <List>
118 {contribs.map((contrib) => (
119 <ListItem>
120 <Flex align="center" gap={12} style="flex:1">
121 <Avatar name={contrib.name} size={36} />
122 <div>
123 <Text size={14} weight={600}>
124 {contrib.name}
125 </Text>
126 <br />
127 <Text size={12} muted>
128 {contrib.email}
129 </Text>
161 <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 />
130205 </div>
131 </Flex>
132 <div style="text-align:right">
133 <Text size={14} weight={600}>
134 {contrib.commits}
135 </Text>
136 <Text size={13} muted>
137 {" "}commit{contrib.commits !== 1 ? "s" : ""}
138 </Text>
206 <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>
139211 </div>
140 </ListItem>
141 ))}
142 </List>
212 </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 }} />
143329 </Layout>
144330 );
145331});
146332
333/** 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 = `
396 .contrib-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
397
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;
415 background: linear-gradient(135deg, #8c6dff, #36c5d6);
416 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
417 }
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 {
428 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
429 -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;
472 background: linear-gradient(90deg, transparent, rgba(140,109,255,0.40), rgba(54,197,214,0.30), transparent);
473 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;
510 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
511 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;
541 background: rgba(140,109,255,0.12);
542 color: #b69dff;
543 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
544 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;
565 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
566 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;
618 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
619 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 {
685 background: rgba(140,109,255,0.16);
686 color: #c4b5fd;
687 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
688 }
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;
709 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
710 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;
719 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
720 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
721 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
147752export default contributors;
148753