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

dashboard.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.

dashboard.tsxBlame1368 lines · 4 contributors
f1ab587Claude1/**
2 * Command Center — the live dashboard.
3 *
4 * This is the developer's mission control. One screen that shows:
5 * - All your repos with health scores at a glance
6 * - Live push feed (what just happened, risk scores, repairs)
7 * - CI status for every repo
8 * - Security alerts
9 * - Quick actions (rollback, repair, deploy)
10 *
11 * Plus intelligence settings — toggle auto-repair, scanning, etc.
12 *
13 * GitHub gives you a feed of stars and follows.
14 * gluecron gives you a COMMAND CENTER.
15 */
16
17import { Hono } from "hono";
18import { eq, desc, and } from "drizzle-orm";
c63b860Claude19import { getCookie, setCookie } from "hono/cookie";
f1ab587Claude20import { db } from "../db";
21import {
22 repositories,
23 users,
24 activityFeed,
25 issues,
26 pullRequests,
27} from "../db/schema";
28import { Layout } from "../views/layout";
febd4f0Claude29import { LiveFeed } from "../views/live-feed";
f1ab587Claude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import {
33 computeHealthScore,
34 detectCIConfig,
35} from "../lib/intelligence";
36import {
37 repoExists,
38 getDefaultBranch,
39 listCommits,
40 listBranches,
41} from "../git/repository";
46d6165Claude42import {
43 computeAiSavingsForUser,
44 computeLifetimeAiSavingsForUser,
45 type AiSavingsReport,
46 type AiSavingsLifetimeReport,
47} from "../lib/ai-hours-saved";
f1ab587Claude48
49const dashboard = new Hono<AuthEnv>();
50
51dashboard.use("*", softAuth);
52
53// ─── COMMAND CENTER ──────────────────────────────────────────
54
55dashboard.get("/dashboard", requireAuth, async (c) => {
56 const user = c.get("user")!;
57
c63b860Claude58 // Block P2 — banner dismiss handler. Set a session cookie and re-redirect
59 // to the bare /dashboard URL so refreshing doesn't keep firing the dismiss.
60 if (c.req.query("p2_dismiss") === "1") {
61 setCookie(c, "p2_verify_dismissed", "1", {
62 path: "/",
63 httpOnly: true,
64 sameSite: "Lax",
65 });
66 return c.redirect("/dashboard");
67 }
68
f1dc7c7Claude69 // ── Loading skeleton (flag-gated) ──
70 // Render an SSR'd structural preview when `?skeleton=1` is present.
71 // Keeps the user oriented on first paint while DB warms up. Behind a
72 // flag until we wire it to streamed/replaced content — we don't ship
73 // a flash before the real markup lands.
74 if (c.req.query("skeleton") === "1") {
75 return c.html(
76 <Layout title="Command Center" user={user}>
77 <DashboardSkeleton />
78 </Layout>
79 );
80 }
81
f1ab587Claude82 // Get all user's repos
83 const repos = await db
84 .select()
85 .from(repositories)
86 .where(eq(repositories.ownerId, user.id))
87 .orderBy(desc(repositories.updatedAt));
88
89 // Compute health scores for all repos (in parallel)
90 const repoData = await Promise.all(
91 repos.map(async (repo) => {
92 let healthScore = 0;
93 let healthGrade = "?" as string;
94 let recentCommits = 0;
95 let branchCount = 0;
96 let ciConfig = null;
97
98 try {
99 if (await repoExists(user.username, repo.name)) {
100 const ref =
101 (await getDefaultBranch(user.username, repo.name)) || "main";
102 const [health, commits, branches, ci] = await Promise.all([
103 computeHealthScore(user.username, repo.name).catch(() => null),
104 listCommits(user.username, repo.name, ref, 5).catch(() => []),
105 listBranches(user.username, repo.name).catch(() => []),
106 detectCIConfig(user.username, repo.name, ref).catch(() => null),
107 ]);
108 if (health) {
109 healthScore = health.score;
110 healthGrade = health.grade;
111 }
112 recentCommits = commits.length;
113 branchCount = branches.length;
114 ciConfig = ci;
115 }
116 } catch {
117 // best effort
118 }
119
120 return {
121 repo,
122 healthScore,
123 healthGrade,
124 recentCommits,
125 branchCount,
126 ciConfig,
127 };
128 })
129 );
130
46d6165Claude131 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
132 // parallel; both helpers swallow DB errors so the dashboard always renders.
133 const [savingsWeek, savingsLifetime] = await Promise.all([
134 computeAiSavingsForUser(user.id, { windowHours: 168 }),
135 computeLifetimeAiSavingsForUser(user.id),
136 ]);
137
f1ab587Claude138 // Get recent activity
139 let recentActivity: Array<{
140 action: string;
141 repoName: string;
142 metadata: string | null;
143 createdAt: Date;
144 }> = [];
145
146 try {
147 const repoIds = repos.map((r) => r.id);
148 if (repoIds.length > 0) {
149 const activity = await db
150 .select({
151 action: activityFeed.action,
152 metadata: activityFeed.metadata,
153 createdAt: activityFeed.createdAt,
154 repoId: activityFeed.repositoryId,
155 })
156 .from(activityFeed)
157 .where(eq(activityFeed.userId, user.id))
158 .orderBy(desc(activityFeed.createdAt))
159 .limit(20);
160
161 recentActivity = activity.map((a) => ({
162 action: a.action,
163 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
164 metadata: a.metadata,
165 createdAt: a.createdAt,
166 }));
167 }
168 } catch {
169 // DB not required for dashboard
170 }
171
172 const gradeColor = (grade: string) =>
173 grade === "A+" || grade === "A"
174 ? "var(--green)"
175 : grade === "B"
176 ? "#58a6ff"
177 : grade === "C"
178 ? "var(--yellow)"
179 : grade === "?"
180 ? "var(--text-muted)"
181 : "var(--red)";
182
c63b860Claude183 // Block P2 — email verification banner. Shows when the user hasn't
184 // verified yet AND they haven't dismissed it this session. Also surfaces
185 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
186 // and the post-register hint (`?welcome=1`).
187 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
188 const showVerifyBanner =
189 !(user as any).emailVerifiedAt && !verifyDismissed;
190 const verifyQuery = c.req.query("verify");
191 const welcomeQuery = c.req.query("welcome");
192
f1ab587Claude193 return c.html(
194 <Layout title="Command Center" user={user}>
c63b860Claude195 {showVerifyBanner && (
196 <div
dc26881CC LABS App197 style="background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.45); color: #e3b341; padding: var(--space-3) var(--space-4); border-radius: 8px; margin-bottom: var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: 14px"
c63b860Claude198 data-p2-verify-banner=""
199 >
200 <div style="flex: 1 1 auto; min-width: 0">
201 {welcomeQuery === "1" ? (
202 <span>
203 Welcome to Gluecron! Check your inbox to verify your email.
204 </span>
205 ) : verifyQuery === "sent" ? (
206 <span>
207 Verification link sent. It may take a minute to arrive.
208 </span>
209 ) : verifyQuery === "rate_limited" ? (
210 <span>
211 You've requested too many verification emails. Try again later.
212 </span>
826eccfTest User213 ) : verifyQuery === "not_configured" ? (
214 <span>
215 Email delivery isn't configured on this instance yet — your
216 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
217 <code>RESEND_API_KEY</code>. Until then the verification link
218 is written to the server log.
219 </span>
c63b860Claude220 ) : (
221 <span>Verify your email to keep using Gluecron.</span>
222 )}
223 </div>
224 <form
225 method="post"
226 action="/verify-email/resend"
dc26881CC LABS App227 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude228 >
229 <input
230 type="hidden"
231 name="_csrf"
232 value={(c.get("csrfToken") as string | undefined) || ""}
233 />
234 <button
235 type="submit"
236 class="btn"
237 style="padding: 4px 10px; font-size: 12px"
238 >
239 Resend verification link
240 </button>
241 <a
242 href="/dashboard?p2_dismiss=1"
243 class="btn"
244 style="padding: 4px 10px; font-size: 12px"
245 aria-label="Dismiss verification banner"
246 >
247 Dismiss
248 </a>
249 </form>
250 </div>
251 )}
a004c46Claude252 <div class="dash-hero">
253 <div class="dash-hero-bg" aria-hidden="true">
254 <div class="dash-hero-orb" />
f1ab587Claude255 </div>
a004c46Claude256 <div class="dash-hero-inner">
257 <div class="dash-hero-text">
258 <div class="dash-hero-eyebrow">
259 {(() => {
260 const hour = new Date().getHours();
261 if (hour < 5) return "Late night,";
262 if (hour < 12) return "Good morning,";
263 if (hour < 17) return "Good afternoon,";
264 if (hour < 21) return "Good evening,";
265 return "Late night,";
266 })()}{" "}
267 <span class="dash-hero-username">{user.username}</span>
268 </div>
269 <h1 class="dash-hero-title">
270 Your{" "}
271 <span class="gradient-text">command center</span>.
272 </h1>
273 <p class="dash-hero-sub">
274 {repos.length === 0
275 ? "Create your first repository to start shipping with AI."
276 : `${repos.length} repo${repos.length === 1 ? "" : "s"} · real-time health, AI activity, and gate status across everything you own.`}
277 </p>
278 </div>
279 <div class="dash-hero-actions">
280 <a href="/new" class="btn btn-primary">+ New repo</a>
281 <a href="/import" class="btn">Import from GitHub</a>
282 <a href="/settings" class="btn">Settings</a>
283 </div>
f1ab587Claude284 </div>
285 </div>
a004c46Claude286 <style
287 dangerouslySetInnerHTML={{
288 __html: `
289 .dash-hero {
290 position: relative;
291 margin-bottom: var(--space-6);
292 padding: var(--space-5) var(--space-6) var(--space-5);
293 background: var(--bg-elevated);
294 border: 1px solid var(--border);
295 border-radius: 16px;
296 overflow: hidden;
297 }
298 .dash-hero::before {
299 content: '';
300 position: absolute;
301 top: 0; left: 0; right: 0;
302 height: 2px;
303 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
304 opacity: 0.7;
305 pointer-events: none;
306 }
307 .dash-hero-bg {
308 position: absolute;
309 inset: -20% -10% auto auto;
310 width: 380px;
311 height: 380px;
312 pointer-events: none;
313 z-index: 0;
314 }
315 .dash-hero-orb {
316 position: absolute;
317 inset: 0;
318 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
319 filter: blur(80px);
320 opacity: 0.7;
321 animation: dashHeroOrb 14s ease-in-out infinite;
322 }
323 @keyframes dashHeroOrb {
324 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
325 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
326 }
327 @media (prefers-reduced-motion: reduce) {
328 .dash-hero-orb { animation: none; }
329 }
330 .dash-hero-inner {
331 position: relative;
332 z-index: 1;
333 display: flex;
334 justify-content: space-between;
335 align-items: flex-end;
336 gap: var(--space-4);
337 flex-wrap: wrap;
338 }
339 .dash-hero-text { flex: 1; min-width: 280px; }
340 .dash-hero-eyebrow {
341 font-size: 13px;
342 color: var(--text-muted);
343 margin-bottom: var(--space-2);
344 letter-spacing: -0.005em;
345 }
346 .dash-hero-username {
347 color: var(--accent);
348 font-weight: 600;
349 }
350 .dash-hero-title {
351 font-size: clamp(28px, 4vw, 40px);
352 font-family: var(--font-display);
353 font-weight: 800;
354 letter-spacing: -0.028em;
355 line-height: 1.05;
356 margin: 0 0 var(--space-2);
357 color: var(--text-strong);
358 }
359 .dash-hero-title .gradient-text {
360 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
361 -webkit-background-clip: text;
362 background-clip: text;
363 -webkit-text-fill-color: transparent;
364 color: transparent;
365 }
366 .dash-hero-sub {
367 font-size: 15px;
368 color: var(--text-muted);
369 margin: 0;
370 line-height: 1.5;
371 max-width: 580px;
372 }
373 .dash-hero-actions {
374 display: flex;
375 gap: var(--space-2);
376 flex-wrap: wrap;
377 }
378 @media (max-width: 720px) {
379 .dash-hero-inner { flex-direction: column; align-items: flex-start; }
380 .dash-hero-actions { width: 100%; }
f1dc7c7Claude381 .dash-hero-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
382 .dash-hero { padding: var(--space-4); }
383 .dash-hero-text { min-width: 0; }
384 .dash-hero-bg { width: 220px; height: 220px; inset: -10% -20% auto auto; }
385 .ai-hours-saved-tabs { flex-wrap: wrap; }
a004c46Claude386 }
387 `,
388 }}
389 />
f1ab587Claude390
46d6165Claude391 {/* ─── L9: AI hours-saved hero widget ─── */}
392 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
393
c6018a5Claude394 {/* ─── Quick actions — surfaces the 5 most-leverage AI features so
395 they're discoverable from the dashboard without diving into the
396 AI dropdown in the top nav. Scoped CSS under .qa- prefix. ─── */}
397 <QuickActionsPanel firstRepo={repos[0]} username={user.username} />
398
f1ab587Claude399 {/* ─── Stats Bar ─── */}
dc26881CC LABS App400 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude401 <StatBox
402 label="Repositories"
403 value={String(repos.length)}
404 color="var(--text-link)"
405 />
406 <StatBox
407 label="Avg Health"
408 value={
409 repos.length > 0
410 ? String(
411 Math.round(
412 repoData.reduce((s, r) => s + r.healthScore, 0) /
413 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
414 )
415 )
416 : "—"
417 }
418 color="var(--green)"
419 />
420 <StatBox
421 label="Total Stars"
422 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
423 color="var(--yellow)"
424 />
425 <StatBox
426 label="Open Issues"
427 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
428 color="var(--red)"
429 />
430 </div>
431
432 {/* ─── Repo Grid ─── */}
433 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
434 {repos.length === 0 ? (
dc26881CC LABS App435 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude436 <div style="text-align:center;margin-bottom:20px">
437 <h2 style="margin-bottom:6px">Get started</h2>
438 <p style="color:var(--text-muted);font-size:14px;margin:0">
439 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
440 </p>
441 </div>
442 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App443 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude444 <div style="flex:1">
445 <div style="font-size:15px;font-weight:600">Create a new repository</div>
446 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
447 Start from scratch with green-ecosystem defaults.
448 </div>
449 </div>
450 <a href="/new" class="btn btn-primary">Create repo</a>
451 </div>
dc26881CC LABS App452 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude453 <div style="flex:1">
454 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
455 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
456 Mirror an existing repo — history, branches, tags.
457 </div>
458 </div>
459 <a href="/import" class="btn">Import repo</a>
460 </div>
dc26881CC LABS App461 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude462 <div style="flex:1">
463 <div style="font-size:15px;font-weight:600">Browse public repos</div>
464 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
465 See what others are building, fork what you like.
466 </div>
467 </div>
468 <a href="/explore" class="btn">Browse</a>
469 </div>
470 </div>
dc26881CC LABS App471 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude472 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
473 Push an existing project (preview)
474 </div>
475 <pre style="margin:0;font-size:12px;overflow-x:auto;color:var(--text-muted)"><code># Once you create a repo, you'll see your real clone URL here.
476git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
477git push -u gluecron main</code></pre>
478 </div>
f1ab587Claude479 </div>
480 ) : (
dc26881CC LABS App481 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude482 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
483 <div class="card" style="padding: 0; overflow: hidden">
484 {/* Health bar at top */}
485 <div
486 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
487 />
dc26881CC LABS App488 <div style="padding: var(--space-4)">
f1ab587Claude489 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
490 <div>
491 <h3 style="font-size: 16px; margin-bottom: 2px">
492 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
493 </h3>
494 {repo.description && (
495 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
496 {repo.description}
497 </p>
498 )}
499 </div>
500 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
501 <div
502 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
503 >
504 {healthGrade}
505 </div>
506 <div style="font-size: 10px; color: var(--text-muted)">
507 {healthScore}/100
508 </div>
509 </div>
510 </div>
511
dc26881CC LABS App512 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude513 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
514 <span>{"\u2606"} {repo.starCount}</span>
515 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
516 </div>
517
518 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App519 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude520 {ciConfig.detected.slice(0, 3).map((d) => (
521 <span
522 class="badge"
523 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
524 >
525 {d}
526 </span>
527 ))}
528 </div>
529 )}
530
dc26881CC LABS App531 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude532 <a
533 href={`/${user.username}/${repo.name}/health`}
534 class="btn btn-sm"
535 style="font-size: 11px; padding: 2px 8px"
536 >
537 Health
538 </a>
539 <a
540 href={`/${user.username}/${repo.name}/dependencies`}
541 class="btn btn-sm"
542 style="font-size: 11px; padding: 2px 8px"
543 >
544 Deps
545 </a>
546 <a
547 href={`/${user.username}/${repo.name}/coupling`}
548 class="btn btn-sm"
549 style="font-size: 11px; padding: 2px 8px"
550 >
551 Insights
552 </a>
553 <a
554 href={`/${user.username}/${repo.name}/settings`}
555 class="btn btn-sm"
556 style="font-size: 11px; padding: 2px 8px"
557 >
558 Settings
559 </a>
560 </div>
561 </div>
562 </div>
563 ))}
564 </div>
565 )}
566
567 {/* ─── Activity Feed ─── */}
568 {recentActivity.length > 0 && (
569 <>
570 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
571 <div class="issue-list">
572 {recentActivity.map((a) => (
573 <div class="issue-item">
dc26881CC LABS App574 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude575 <ActivityIcon action={a.action} />
576 <div>
577 <span style="font-size: 14px">
578 {formatAction(a.action)} in{" "}
579 <a href={`/${user.username}/${a.repoName}`}>
580 {a.repoName}
581 </a>
582 </span>
583 <div style="font-size: 12px; color: var(--text-muted)">
584 {formatRelative(a.createdAt)}
585 </div>
586 </div>
587 </div>
588 </div>
589 ))}
590 </div>
591 </>
592 )}
593
7a14837Claude594 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
595 <HealthCoach repoData={repoData} username={user.username} />
596
febd4f0Claude597 {/* ─── Live Activity (SSE) ─── */}
598 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
599
f1ab587Claude600 {/* ─── Quick Links ─── */}
dc26881CC LABS App601 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude602 <a href="/explore" class="btn">Browse public repos</a>
603 <a href="/settings/tokens" class="btn">API tokens</a>
604 <a href="/settings/keys" class="btn">SSH keys</a>
605 </div>
606 </Layout>
607 );
608});
609
610// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
611
612dashboard.get(
613 "/:owner/:repo/settings/intelligence",
614 requireAuth,
615 async (c) => {
616 const { owner: ownerName, repo: repoName } = c.req.param();
617 const user = c.get("user")!;
618 const success = c.req.query("success");
619
620 return c.html(
621 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
622 <div style="max-width: 600px">
623 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
624 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
625 Control what gluecron does automatically when code is pushed to{" "}
626 <strong>{ownerName}/{repoName}</strong>.
627 </p>
628 {success && (
629 <div class="auth-success">{decodeURIComponent(success)}</div>
630 )}
631 <form
0316dbbClaude632 method="post"
f1ab587Claude633 action={`/${ownerName}/${repoName}/settings/intelligence`}
634 >
635 <ToggleSetting
636 name="auto_repair"
637 label="Auto-Repair"
638 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
639 defaultChecked={true}
640 />
641 <ToggleSetting
642 name="security_scan"
643 label="Security Scanning"
644 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
645 defaultChecked={true}
646 />
647 <ToggleSetting
648 name="health_score"
649 label="Health Score"
650 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
651 defaultChecked={true}
652 />
653 <ToggleSetting
654 name="push_analysis"
655 label="Push Risk Analysis"
656 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
657 defaultChecked={true}
658 />
659 <ToggleSetting
660 name="dep_analysis"
661 label="Dependency Analysis"
662 description="Build import graph, detect unused deps, find circular dependencies"
663 defaultChecked={true}
664 />
665 <ToggleSetting
666 name="gatetest"
667 label="GateTest Integration"
668 description="Send push events to GateTest for external security scanning"
669 defaultChecked={true}
670 />
671 <ToggleSetting
90fa787Claude672 name="deploy_webhook"
673 label="Auto-Deploy Webhook"
674 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude675 defaultChecked={true}
676 />
677
678 <button
679 type="submit"
680 class="btn btn-primary"
681 style="margin-top: 12px"
682 >
683 Save settings
684 </button>
685 </form>
686 </div>
687 </Layout>
688 );
689 }
690);
691
692dashboard.post(
693 "/:owner/:repo/settings/intelligence",
694 requireAuth,
695 async (c) => {
696 const { owner: ownerName, repo: repoName } = c.req.param();
697 // In production, these would be saved to DB per-repo
698 // For now, acknowledge the settings
699 return c.redirect(
700 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
701 );
702 }
703);
704
705// ─── PUSH LOG ────────────────────────────────────────────────
706
707dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
708 const { owner, repo } = c.req.param();
709 const user = c.get("user");
710
711 if (!(await repoExists(owner, repo))) return c.notFound();
712 const ref = (await getDefaultBranch(owner, repo)) || "main";
713 const commits = await listCommits(owner, repo, ref, 30);
714
715 return c.html(
716 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
717 <div style="max-width: 900px">
718 <h2 style="margin-bottom: 4px">Push Log</h2>
719 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
720 Every push analyzed in real-time — risk scores, repairs, security alerts
721 </p>
722 <div class="issue-list">
723 {commits.map((commit) => {
724 // Determine if this was an auto-repair commit
725 const isRepair =
726 commit.author === "gluecron[bot]" ||
727 commit.message.includes("auto-repair");
728 const isRollback = commit.message.startsWith("revert: rollback");
729
730 return (
731 <div class="issue-item" style="flex-direction: column; align-items: stretch">
732 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App733 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude734 {isRepair ? (
735 <span
736 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
737 title="Auto-repair"
738 >
739 {"⚡"}
740 </span>
741 ) : isRollback ? (
742 <span
743 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
744 title="Rollback"
745 >
746 {"↩"}
747 </span>
748 ) : (
749 <span
750 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
751 >
752 {"→"}
753 </span>
754 )}
755 <div>
756 <a
757 href={`/${owner}/${repo}/commit/${commit.sha}`}
758 style="font-weight: 600; font-size: 14px"
759 >
760 {commit.message}
761 </a>
762 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
763 {commit.author} —{" "}
764 {new Date(commit.date).toLocaleString("en-US", {
765 month: "short",
766 day: "numeric",
767 hour: "2-digit",
768 minute: "2-digit",
769 })}
770 </div>
771 </div>
772 </div>
773 <a
774 href={`/${owner}/${repo}/commit/${commit.sha}`}
775 class="commit-sha"
776 >
777 {commit.sha.slice(0, 7)}
778 </a>
779 </div>
780 {isRepair && (
781 <div
dc26881CC LABS App782 style="margin-top: var(--space-2); padding: var(--space-2) var(--space-3); background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
f1ab587Claude783 >
784 Automatically repaired by gluecron
785 </div>
786 )}
787 </div>
788 );
789 })}
790 </div>
791 </div>
792 </Layout>
793 );
794});
795
796// ─── COMPONENTS ──────────────────────────────────────────────
797
46d6165Claude798/**
799 * Block L9 — pure formatter used by the dashboard widget AND tests.
800 * Turns the breakdown into the small stat-pill array shown under the
801 * big number. Exported so the markup contract is testable without
802 * importing JSX.
803 */
804export function formatSavingsPills(b: {
805 prsAutoMerged: number;
806 issuesBuiltByAi: number;
807 aiReviewsPosted: number;
808 aiTriagesPosted: number;
809 aiCommitMsgs: number;
810 secretsAutoRepaired: number;
811 gateAutoRepairs: number;
812}): string[] {
813 const pills: string[] = [];
814 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
815 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
816 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
817 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
818 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
819 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
820 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
821 return pills;
822}
823
824const AiHoursSavedWidget = ({
825 week,
826 lifetime,
827}: {
828 week: AiSavingsReport;
829 lifetime: AiSavingsLifetimeReport;
830}) => {
831 const weekPills = formatSavingsPills(week.breakdown);
832 const lifetimePills = formatSavingsPills(lifetime.breakdown);
833 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
834 return (
835 <div
836 class="card ai-hours-saved-widget"
837 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
838 >
dc26881CC LABS App839 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
840 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude841 <div style="flex:1;min-width:240px">
842 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
843 AI working for you
844 </div>
845 <div
846 data-testid="ai-hours-saved-this-week"
847 style="font-size: 56px; font-weight: 800; line-height: 1; background: var(--accent-gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent"
848 >
849 {week.hoursSaved.toFixed(1)}h
850 </div>
851 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
852 Claude saved you{" "}
853 <strong style="color: var(--text)">
854 {week.hoursSaved.toFixed(1)} hours
855 </strong>{" "}
856 this week.
857 {lifetime.hoursSaved > week.hoursSaved && (
858 <span>
859 {" — "}
860 <strong style="color: var(--text)">
861 {lifetime.hoursSaved.toFixed(1)}h
862 </strong>{" "}
863 all-time.
864 </span>
865 )}
866 </div>
867 </div>
868 <div
869 class="ai-hours-saved-tabs"
870 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
871 >
872 <span
873 data-tab="this-week"
874 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
875 >
876 This week
877 </span>
878 <span
879 data-tab="all-time"
880 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
881 >
882 All-time
883 </span>
884 </div>
885 </div>
886
887 {hasAnyWeek ? (
dc26881CC LABS App888 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude889 {weekPills.map((p) => (
890 <span
891 class="badge"
892 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
893 >
894 {p}
895 </span>
896 ))}
897 </div>
898 ) : (
899 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
900 No AI activity this week yet — open a PR, label an issue{" "}
901 <code>ai:build</code>, or let auto-merge sweep your branches.
902 The counter will start climbing.
903 </div>
904 )}
905
906 <details style="margin-top:16px">
907 <summary
908 data-testid="ai-hours-saved-formula-toggle"
909 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
910 >
911 How is this calculated?
912 </summary>
913 <div
914 style="margin-top:10px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);font-size:12px;color:var(--text-muted);font-family:var(--font-mono, monospace);line-height:1.6"
915 >
916 <div>hoursSaved =</div>
917 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
918 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
919 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
920 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
921 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
922 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
923 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
924 <div style="margin-top:6px;color:var(--text)">
925 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
926 {week.windowHours}h window)
927 </div>
928 <div style="margin-top:8px;font-size:11px">
929 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
930 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
931 Constants are conservative on purpose — audit-friendly is
932 the brand.
933 </div>
934 {lifetimePills.length > 0 && (
935 <div style="margin-top:8px;font-size:11px">
936 All-time breakdown: {lifetimePills.join(" · ")}
937 </div>
938 )}
939 </div>
940 </details>
941 </div>
942 </div>
943 );
944};
945
7a14837Claude946/**
947 * Pure helper: pick the bottom-N repos by health score and return a
948 * prioritized "fix this next" plan. Health=0 repos (couldn't be
949 * computed) are excluded so the coach doesn't recommend ghost repos.
950 *
951 * Exported under __test for unit testing without touching the DB.
952 */
953export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
954 repoData: T[],
955 topN = 3
956): T[] {
957 return repoData
958 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
959 .sort((a, b) => a.healthScore - b.healthScore)
960 .slice(0, topN);
961}
962
963/** Module-scoped color picker for grade chips. Mirrors the inner
964 * `gradeColor` defined in the request handler scope, exposed at module
965 * level so HealthCoach (also module-scope) can reach it. */
966function moduleGradeColor(grade: string): string {
967 if (grade === "A+" || grade === "A") return "var(--green)";
968 if (grade === "B") return "#58a6ff";
969 if (grade === "C") return "var(--yellow)";
970 if (grade === "?") return "var(--text-muted)";
971 return "var(--red)";
972}
973
974const HealthCoach = ({
975 repoData,
976 username,
977}: {
978 repoData: Array<{
979 repo: { name: string; description: string | null };
980 healthScore: number;
981 healthGrade: string;
982 }>;
983 username: string;
984}) => {
985 const picks = pickRepoCoachPicks(repoData, 3);
986 if (picks.length === 0) {
987 return (
988 <div
989 class="card"
dc26881CC LABS App990 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude991 >
992 <h3 style="margin: 0 0 4px; font-size: 15px">
993 {"✨"} AI Health Coach
994 </h3>
995 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
996 All your repos are healthy (score &ge; 90). Nothing to triage.
997 </p>
998 </div>
999 );
1000 }
1001 return (
1002 <div
1003 class="card"
1004 style="margin-bottom: 32px; padding: 0; overflow: hidden"
1005 >
1006 <div
dc26881CC LABS App1007 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude1008 >
1009 <div>
1010 <h3 style="margin: 0; font-size: 15px">
1011 {"✨"} AI Health Coach
1012 </h3>
1013 <p
dc26881CC LABS App1014 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude1015 >
1016 Top {picks.length} repos that would benefit from attention
1017 this week.
1018 </p>
1019 </div>
1020 </div>
1021 <ul style="list-style: none; margin: 0; padding: 0">
1022 {picks.map((p) => (
1023 <li
dc26881CC LABS App1024 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude1025 >
1026 <div
1027 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
1028 >
1029 {p.healthGrade}
1030 </div>
1031 <div style="flex: 1; min-width: 0">
1032 <a
1033 href={`/${username}/${p.repo.name}`}
1034 style="font-weight: 500"
1035 >
1036 {p.repo.name}
1037 </a>
1038 <div
1039 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
1040 >
1041 Health score {p.healthScore}/100 — open the repo to see
1042 breakdown + AI suggestions.
1043 </div>
1044 </div>
1045 <a
f077ea5Claude1046 href={`/${username}/${p.repo.name}/health`}
7a14837Claude1047 class="btn"
1048 style="font-size: 12px; padding: 4px 10px"
f077ea5Claude1049 title="Open health score with AI suggestions"
7a14837Claude1050 >
1051 Coach me
1052 </a>
1053 </li>
1054 ))}
1055 </ul>
1056 </div>
1057 );
1058};
1059
f1ab587Claude1060const StatBox = ({
1061 label,
1062 value,
1063 color,
1064}: {
1065 label: string;
1066 value: string;
1067 color: string;
1068}) => (
1069 <div
dc26881CC LABS App1070 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude1071 >
1072 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
1073 {value}
1074 </div>
1075 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1076 {label}
1077 </div>
1078 </div>
1079);
1080
1081const ToggleSetting = ({
1082 name,
1083 label,
1084 description,
1085 defaultChecked,
1086}: {
1087 name: string;
1088 label: string;
1089 description: string;
1090 defaultChecked: boolean;
1091}) => (
1092 <div
dc26881CC LABS App1093 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude1094 >
1095 <div style="flex: 1">
1096 <div style="font-size: 15px; font-weight: 600">{label}</div>
1097 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
1098 {description}
1099 </div>
1100 </div>
1101 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]1102 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude1103 <span class="toggle-slider" />
1104 </label>
1105 </div>
1106);
1107
1108const ActivityIcon = ({ action }: { action: string }) => {
1109 const icons: Record<string, string> = {
1110 push: "→",
1111 issue_open: "\u25CB",
1112 issue_close: "\u2713",
1113 pr_open: "\u25CB",
1114 pr_merge: "\u2B8C",
1115 star: "\u2605",
1116 fork: "\u2442",
1117 comment: "\u{1F4AC}",
1118 };
1119 return (
1120 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
1121 {icons[action] || "•"}
1122 </span>
1123 );
1124};
1125
1126function formatAction(action: string): string {
1127 const labels: Record<string, string> = {
1128 push: "Pushed code",
1129 issue_open: "Opened issue",
1130 issue_close: "Closed issue",
1131 pr_open: "Opened pull request",
1132 pr_merge: "Merged pull request",
1133 star: "Starred",
1134 fork: "Forked",
1135 comment: "Commented",
1136 };
1137 return labels[action] || action;
1138}
1139
1140function formatRelative(date: Date | string): string {
1141 const d = typeof date === "string" ? new Date(date) : date;
1142 const now = new Date();
1143 const diffMs = now.getTime() - d.getTime();
1144 const diffMins = Math.floor(diffMs / 60000);
1145 if (diffMins < 1) return "just now";
1146 if (diffMins < 60) return `${diffMins}m ago`;
1147 const diffHours = Math.floor(diffMins / 60);
1148 if (diffHours < 24) return `${diffHours}h ago`;
1149 const diffDays = Math.floor(diffHours / 24);
1150 if (diffDays < 30) return `${diffDays}d ago`;
1151 return d.toLocaleDateString("en-US", {
1152 month: "short",
1153 day: "numeric",
1154 });
1155}
1156
f1dc7c7Claude1157// ─── Loading skeleton (flag-gated; renders when ?skeleton=1) ──────────
1158const DashboardSkeleton = () => (
1159 <>
1160 <style
1161 dangerouslySetInnerHTML={{
1162 __html: `
1163 .dash-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: dashSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
1164 @keyframes dashSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1165 @media (prefers-reduced-motion: reduce) { .dash-skel { animation: none; } }
1166 .dash-skel-hero { height: 168px; border-radius: 16px; margin-bottom: var(--space-6); }
1167 .dash-skel-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8); }
1168 .dash-skel-stat { height: 86px; border-radius: var(--radius); }
1169 .dash-skel-h { height: 18px; width: 180px; margin: 0 0 16px; border-radius: 5px; }
1170 .dash-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
1171 .dash-skel-card { height: 168px; border-radius: var(--radius); }
1172 .dash-skel-feed { display: flex; flex-direction: column; gap: 8px; }
1173 .dash-skel-feed-row { height: 52px; border-radius: var(--radius); }
1174 `,
1175 }}
1176 />
1177 <div class="dash-skel dash-skel-hero" aria-hidden="true" />
1178 <div class="dash-skel-stats" aria-hidden="true">
1179 <div class="dash-skel dash-skel-stat" />
1180 <div class="dash-skel dash-skel-stat" />
1181 <div class="dash-skel dash-skel-stat" />
1182 <div class="dash-skel dash-skel-stat" />
1183 </div>
1184 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1185 <div class="dash-skel-grid" aria-hidden="true">
1186 <div class="dash-skel dash-skel-card" />
1187 <div class="dash-skel dash-skel-card" />
1188 <div class="dash-skel dash-skel-card" />
1189 <div class="dash-skel dash-skel-card" />
1190 </div>
1191 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1192 <div class="dash-skel-feed" aria-hidden="true">
1193 <div class="dash-skel dash-skel-feed-row" />
1194 <div class="dash-skel dash-skel-feed-row" />
1195 <div class="dash-skel dash-skel-feed-row" />
1196 <div class="dash-skel dash-skel-feed-row" />
1197 <div class="dash-skel dash-skel-feed-row" />
1198 </div>
1199 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
1200 Loading your command center…
1201 </span>
1202 </>
1203);
1204
c6018a5Claude1205// ─── Quick actions — 5-card panel of the most-leverage AI features.
1206// Surfaces the post-K1 AI surfaces (Voice, Standups, Refactors, repo
1207// chat, Pulls dashboard) so signed-in users can discover them without
1208// hunting through the top-nav dropdown. CSS scoped under `.qa-` to
1209// avoid bleed; no shared component touched.
1210const QuickActionsPanel = ({
1211 firstRepo,
1212 username,
1213}: {
1214 firstRepo: { name: string } | undefined;
1215 username: string;
1216}) => {
1217 const chatHref = firstRepo
1218 ? `/${username}/${firstRepo.name}/chat`
1219 : "/explore";
1220 const chatSub = firstRepo
1221 ? `Open ${firstRepo.name} rubber-duck chat`
1222 : "Pick a repo, then ask anything";
1223 return (
1224 <div class="qa-panel" aria-label="Quick AI actions">
1225 <style
1226 dangerouslySetInnerHTML={{
1227 __html: `
1228 .qa-panel {
1229 position: relative;
1230 margin-bottom: var(--space-6);
1231 padding: var(--space-4) var(--space-5) var(--space-5);
1232 background: var(--bg-elevated);
1233 border: 1px solid var(--border);
1234 border-radius: 14px;
1235 overflow: hidden;
1236 }
1237 .qa-panel::before {
1238 content: '';
1239 position: absolute;
1240 top: 0; left: 0; right: 0;
1241 height: 1px;
1242 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1243 opacity: 0.65;
1244 pointer-events: none;
1245 }
1246 .qa-eyebrow {
1247 font-size: 11px;
1248 font-weight: 600;
1249 letter-spacing: 0.08em;
1250 text-transform: uppercase;
1251 color: var(--accent);
1252 margin-bottom: 4px;
1253 }
1254 .qa-title {
1255 font-family: var(--font-display);
1256 font-size: 17px;
1257 font-weight: 700;
1258 letter-spacing: -0.018em;
1259 margin: 0 0 var(--space-3);
1260 color: var(--text-strong);
1261 }
1262 .qa-grid {
1263 display: grid;
1264 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1265 gap: var(--space-2);
1266 }
1267 .qa-card {
1268 display: flex;
1269 flex-direction: column;
1270 gap: 4px;
1271 padding: var(--space-3) var(--space-3);
1272 background: var(--bg);
1273 border: 1px solid var(--border);
1274 border-radius: 10px;
1275 color: var(--text);
1276 text-decoration: none;
1277 transition: border-color 160ms ease, transform 160ms ease, background 160ms ease;
1278 position: relative;
1279 overflow: hidden;
1280 }
1281 .qa-card::before {
1282 content: '';
1283 position: absolute;
1284 top: 0; left: 0; right: 0;
1285 height: 1px;
1286 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
1287 opacity: 0;
1288 transition: opacity 160ms ease;
1289 }
1290 .qa-card:hover {
1291 border-color: rgba(140,109,255,0.40);
1292 text-decoration: none;
1293 transform: translateY(-1px);
1294 background: linear-gradient(180deg, rgba(140,109,255,0.04), transparent);
1295 }
1296 .qa-card:hover::before { opacity: 1; }
1297 .qa-card-label {
1298 font-size: 13.5px;
1299 font-weight: 600;
1300 color: var(--text-strong);
1301 display: flex;
1302 align-items: center;
1303 gap: 6px;
1304 }
1305 .qa-card-sub {
1306 font-size: 12px;
1307 color: var(--text-muted);
1308 line-height: 1.4;
1309 }
1310 .qa-card-icon {
1311 display: inline-flex;
1312 width: 18px; height: 18px;
1313 align-items: center;
1314 justify-content: center;
1315 border-radius: 5px;
1316 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
1317 color: #b69dff;
1318 font-size: 11px;
1319 font-weight: 700;
1320 flex-shrink: 0;
1321 }
1322 `,
1323 }}
1324 />
1325 <div class="qa-eyebrow">Quick actions</div>
1326 <h3 class="qa-title">Ship faster with Claude</h3>
1327 <div class="qa-grid">
1328 <a href="/voice" class="qa-card">
1329 <span class="qa-card-label">
1330 <span class="qa-card-icon" aria-hidden="true">{"\u{1F3A4}"}</span>
1331 Talk to ship
1332 </span>
1333 <span class="qa-card-sub">Voice → PR in one take</span>
1334 </a>
1335 <a href="/standups" class="qa-card">
1336 <span class="qa-card-label">
1337 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4DD}"}</span>
1338 Today's standup
1339 </span>
1340 <span class="qa-card-sub">Daily AI brief, fresh on demand</span>
1341 </a>
1342 <a href="/refactors" class="qa-card">
1343 <span class="qa-card-label">
1344 <span class="qa-card-icon" aria-hidden="true">{"⚡"}</span>
1345 Refactor everywhere
1346 </span>
1347 <span class="qa-card-sub">One brief → PRs across all repos</span>
1348 </a>
1349 <a href={chatHref} class="qa-card">
1350 <span class="qa-card-label">
1351 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
1352 Chat with a repo
1353 </span>
1354 <span class="qa-card-sub">{chatSub}</span>
1355 </a>
1356 <a href="/pulls" class="qa-card">
1357 <span class="qa-card-label">
1358 <span class="qa-card-icon" aria-hidden="true">{"\u{1F500}"}</span>
1359 Watch the PR queue
1360 </span>
1361 <span class="qa-card-sub">Global pulls across your repos</span>
1362 </a>
1363 </div>
1364 </div>
1365 );
1366};
1367
f1ab587Claude1368export default dashboard;