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

admin-advancement.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.

admin-advancement.tsxBlame1060 lines · 1 contributor
d199847Claude1/**
2 * /admin/advancement — site-admin surface for the weekly advancement
3 * scanner (`src/lib/advancement-scanner.ts`).
4 *
5 * GET /admin/advancement — hero + counters + recent findings
6 * POST /admin/advancement/run — kick off a scan synchronously
7 * POST /admin/advancement/settings — toggle "Enable weekly scan"
8 * POST /admin/advancement/dismiss/:id — close a finding-issue
9 * POST /admin/advancement/promote/:id — re-fire the migration PR
10 * for a stack-bump finding
11 *
12 * All endpoints gated behind `requireAuth` + `isSiteAdmin`.
13 *
14 * Visual recipe (mirrors admin-status / admin-self-host / admin-ops):
15 * - Gradient hairline strip at the top of the hero (purple → cyan)
16 * - Radial orb in the corner of the hero
17 * - Eyebrow with pill icon + actor name
18 * - Display headline with gradient-text on the verb
19 * - Stat-counter row with tabular-nums numbers
20 * - List of recent findings with per-row actions
21 * - Settings card with a single toggle
22 *
23 * Scoped CSS — every class prefixed `.adv-scan-` so this surface can't
24 * bleed into other admin pages.
25 */
26/* eslint-disable @typescript-eslint/no-explicit-any */
27
28import { Hono } from "hono";
29import { and, desc, eq, gte, sql } from "drizzle-orm";
30import { Layout } from "../views/layout";
31import { softAuth } from "../middleware/auth";
32import type { AuthEnv } from "../middleware/auth";
33import { isSiteAdmin } from "../lib/admin";
34import { db } from "../db";
35import { auditLog, issueLabels, issues, labels } from "../db/schema";
36import {
37 ADVANCEMENT_AUDIT_ACTION,
38 ADVANCEMENT_LABEL_NAME,
39 ADVANCEMENT_SCAN_COMPLETE_ACTION,
40 ADVANCEMENT_DEFAULT_SELF_HOST_REPO,
41 runAdvancementScan,
42} from "../lib/advancement-scanner";
43import { getConfigValue, setConfigValue } from "../lib/system-config";
44import { audit } from "../lib/notify";
45import { repositories, users } from "../db/schema";
46
47const advancement = new Hono<AuthEnv>();
48advancement.use("*", softAuth);
49
50const ENABLED_CONFIG_KEY = "ADVANCEMENT_SCAN_ENABLED";
51const ENABLED_ENV_FALLBACK = "ADVANCEMENT_SCAN_ENABLED";
52
53async function gate(c: any): Promise<{ user: any } | Response> {
54 const user = c.get("user");
55 if (!user) return c.redirect("/login?next=/admin/advancement");
56 if (!(await isSiteAdmin(user.id))) {
57 return c.html(
58 <Layout title="Forbidden" user={user}>
59 <div class="adv-scan-403">
60 <h2>403 — Not a site admin</h2>
61 <p>You don't have permission to view this page.</p>
62 </div>
63 <style dangerouslySetInnerHTML={{ __html: ADV_SCAN_CSS }} />
64 </Layout>,
65 403
66 );
67 }
68 return { user };
69}
70
71function redirectWith(
72 c: any,
73 kind: "success" | "error",
74 msg: string
75): Response {
76 return c.redirect(
77 `/admin/advancement?${kind}=${encodeURIComponent(msg)}`
78 );
79}
80
81function fmtAgo(t: Date | undefined | null): string {
82 if (!t) return "never";
83 const ms = Date.now() - t.getTime();
84 if (ms < 5_000) return "just now";
85 const s = Math.floor(ms / 1000);
86 if (s < 60) return `${s}s ago`;
87 const m = Math.floor(s / 60);
88 if (m < 60) return `${m}m ago`;
89 const h = Math.floor(m / 60);
90 if (h < 24) return `${h}h ago`;
91 const d = Math.floor(h / 24);
92 return `${d}d ago`;
93}
94
95interface RecentFinding {
96 issueId: string;
97 issueNumber: number;
98 title: string;
99 kind: string;
100 urgency: string;
101 state: string;
102 createdAt: Date;
103}
104
105/** Resolve the self-host repo (mirrors the lib helper but UI-only). */
106async function resolveSelfHostRepoUi(): Promise<{
107 repositoryId: string;
108 ownerName: string;
109 repoName: string;
110} | null> {
111 const fullName =
112 process.env.SELF_HOST_REPO || ADVANCEMENT_DEFAULT_SELF_HOST_REPO;
113 const [ownerName, repoName] = fullName.includes("/")
114 ? fullName.split("/")
115 : [fullName, "Gluecron.com"];
116 try {
117 const [row] = await db
118 .select({ repositoryId: repositories.id })
119 .from(repositories)
120 .innerJoin(users, eq(users.id, repositories.ownerId))
121 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
122 .limit(1);
123 if (!row) return null;
124 return { repositoryId: row.repositoryId, ownerName, repoName };
125 } catch {
126 return null;
127 }
128}
129
130async function loadRecentFindings(
131 repositoryId: string
132): Promise<RecentFinding[]> {
133 try {
134 const [lab] = await db
135 .select({ id: labels.id })
136 .from(labels)
137 .where(
138 and(
139 eq(labels.repositoryId, repositoryId),
140 eq(labels.name, ADVANCEMENT_LABEL_NAME)
141 )
142 )
143 .limit(1);
144 if (!lab) return [];
145 const rows = await db
146 .select({
147 issueId: issues.id,
148 number: issues.number,
149 title: issues.title,
150 body: issues.body,
151 state: issues.state,
152 createdAt: issues.createdAt,
153 })
154 .from(issues)
155 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
156 .where(
157 and(eq(issueLabels.labelId, lab.id), eq(issues.repositoryId, repositoryId))
158 )
159 .orderBy(desc(issues.createdAt))
160 .limit(25);
161 return rows.map((r) => ({
162 issueId: r.issueId,
163 issueNumber: r.number ?? 0,
164 title: r.title,
165 kind: extractFromBody(r.body, "Kind") || "—",
166 urgency: extractFromBody(r.body, "Urgency") || "—",
167 state: r.state,
168 createdAt: r.createdAt,
169 }));
170 } catch {
171 return [];
172 }
173}
174
175/**
176 * Cheap parser for the markdown headers our renderer embeds:
177 * `**Kind:** Stack version bump`
178 * Pulls out the value after the colon. Returns "" on miss.
179 */
180function extractFromBody(body: string | null, label: string): string {
181 if (!body) return "";
182 const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+?)(?:\\n|$)`, "i");
183 const m = body.match(re);
184 if (!m) return "";
185 // Strip leading emoji + colon-prefix gunk like ":red_circle:"
186 return m[1].replace(/^:[a-z_]+:\s*/i, "").trim();
187}
188
189interface ScanStats {
190 thisWeek: number;
191 openIssues: number;
192 shippedThisMonth: number;
193 lastScanAt: Date | null;
194}
195
196async function loadStats(repositoryId: string): Promise<ScanStats> {
197 const weekCutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
198 const monthCutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
199 let thisWeek = 0;
200 let openIssues = 0;
201 let shippedThisMonth = 0;
202 let lastScanAt: Date | null = null;
203 try {
204 const [w] = await db
205 .select({ c: sql<number>`count(*)::int` })
206 .from(auditLog)
207 .where(
208 and(
209 eq(auditLog.action, ADVANCEMENT_AUDIT_ACTION),
210 gte(auditLog.createdAt, weekCutoff)
211 )
212 );
213 thisWeek = w?.c ?? 0;
214 } catch {
215 /* empty */
216 }
217 try {
218 const [lab] = await db
219 .select({ id: labels.id })
220 .from(labels)
221 .where(
222 and(
223 eq(labels.repositoryId, repositoryId),
224 eq(labels.name, ADVANCEMENT_LABEL_NAME)
225 )
226 )
227 .limit(1);
228 if (lab) {
229 const [open] = await db
230 .select({ c: sql<number>`count(*)::int` })
231 .from(issues)
232 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
233 .where(
234 and(
235 eq(issueLabels.labelId, lab.id),
236 eq(issues.state, "open"),
237 eq(issues.repositoryId, repositoryId)
238 )
239 );
240 openIssues = open?.c ?? 0;
241 const [shipped] = await db
242 .select({ c: sql<number>`count(*)::int` })
243 .from(issues)
244 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
245 .where(
246 and(
247 eq(issueLabels.labelId, lab.id),
248 eq(issues.state, "closed"),
249 eq(issues.repositoryId, repositoryId),
250 gte(issues.closedAt, monthCutoff)
251 )
252 );
253 shippedThisMonth = shipped?.c ?? 0;
254 }
255 } catch {
256 /* empty */
257 }
258 try {
259 const [s] = await db
260 .select({ createdAt: auditLog.createdAt })
261 .from(auditLog)
262 .where(eq(auditLog.action, ADVANCEMENT_SCAN_COMPLETE_ACTION))
263 .orderBy(desc(auditLog.createdAt))
264 .limit(1);
265 lastScanAt = s?.createdAt ?? null;
266 } catch {
267 /* empty */
268 }
269 return { thisWeek, openIssues, shippedThisMonth, lastScanAt };
270}
271
272async function isScanEnabled(): Promise<boolean> {
273 const v = await getConfigValue(ENABLED_CONFIG_KEY, ENABLED_ENV_FALLBACK);
274 if (!v) return true; // default-on when nothing's set
275 return v === "1" || v.toLowerCase() === "true";
276}
277
278// ---------------------------------------------------------------------------
279// SVG icons (private, no shared-component edits)
280// ---------------------------------------------------------------------------
281
282function IconArrowLeft() {
283 return (
284 <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">
285 <line x1="19" y1="12" x2="5" y2="12" />
286 <polyline points="12 19 5 12 12 5" />
287 </svg>
288 );
289}
290function IconBolt() {
291 return (
292 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
293 <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
294 </svg>
295 );
296}
297function IconPlay() {
298 return (
299 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
300 <polygon points="5 3 19 12 5 21 5 3" />
301 </svg>
302 );
303}
304
305// ---------------------------------------------------------------------------
306// GET /admin/advancement
307// ---------------------------------------------------------------------------
308
309advancement.get("/admin/advancement", async (c) => {
310 const g = await gate(c);
311 if (g instanceof Response) return g;
312 const { user } = g;
313
314 const success = c.req.query("success");
315 const error = c.req.query("error");
316
317 const repo = await resolveSelfHostRepoUi();
318 const [stats, recent, enabled] = await Promise.all([
319 repo
320 ? loadStats(repo.repositoryId)
321 : Promise.resolve<ScanStats>({
322 thisWeek: 0,
323 openIssues: 0,
324 shippedThisMonth: 0,
325 lastScanAt: null,
326 }),
327 repo ? loadRecentFindings(repo.repositoryId) : Promise.resolve([]),
328 isScanEnabled(),
329 ]);
330
331 return c.html(
332 <Layout title="Advancement scanner — admin" user={user}>
333 <div class="adv-scan-wrap">
334 {/* Hero */}
335 <section class="adv-scan-hero">
336 <div class="adv-scan-hero-orb" aria-hidden="true" />
337 <div class="adv-scan-hero-inner">
338 <div class="adv-scan-hero-top">
339 <div class="adv-scan-hero-text">
340 <div class="adv-scan-eyebrow">
341 <span class="adv-scan-eyebrow-pill" aria-hidden="true">
342 <IconBolt />
343 </span>
344 Advancement scanner · Site admin ·{" "}
345 <span class="adv-scan-who">{user.username}</span>
346 </div>
347 <h1 class="adv-scan-title">
348 <span class="adv-scan-title-grad">What we should ship next.</span>
349 </h1>
350 <p class="adv-scan-sub">
351 Weekly Claude-driven scan for new Claude model releases,
352 framework versions in our stack, self-improvement patterns,
353 and trending features competitors shipped. Findings open as
354 issues on{" "}
355 <code>
356 {process.env.SELF_HOST_REPO ||
357 ADVANCEMENT_DEFAULT_SELF_HOST_REPO}
358 </code>
359 {" "}— straightforward dependency bumps are auto-promoted
360 to PRs via the migration assistant.
361 </p>
362 </div>
363 <a href="/admin" class="adv-scan-hero-back">
364 <IconArrowLeft />
365 Back to admin
366 </a>
367 </div>
368 </div>
369 </section>
370
371 {success && (
372 <div class="adv-scan-banner is-ok" role="status">
373 <span class="adv-scan-banner-dot" aria-hidden="true" />
374 {decodeURIComponent(success)}
375 </div>
376 )}
377 {error && (
378 <div class="adv-scan-banner is-error" role="alert">
379 <span class="adv-scan-banner-dot" aria-hidden="true" />
380 {decodeURIComponent(error)}
381 </div>
382 )}
383
384 {/* Stat counters */}
385 <section class="adv-scan-stats" aria-label="Scanner statistics">
386 <div class="adv-scan-stat">
387 <div class="adv-scan-stat-label">Findings this week</div>
388 <div class="adv-scan-stat-value adv-scan-tabular">
389 {stats.thisWeek}
390 </div>
391 </div>
392 <div class="adv-scan-stat">
393 <div class="adv-scan-stat-label">Open improvement issues</div>
394 <div class="adv-scan-stat-value adv-scan-tabular">
395 {stats.openIssues}
396 </div>
397 </div>
398 <div class="adv-scan-stat">
399 <div class="adv-scan-stat-label">Shipped this month</div>
400 <div class="adv-scan-stat-value adv-scan-tabular">
401 {stats.shippedThisMonth}
402 </div>
403 </div>
404 <div class="adv-scan-stat">
405 <div class="adv-scan-stat-label">Last scan</div>
406 <div class="adv-scan-stat-value adv-scan-tabular">
407 {fmtAgo(stats.lastScanAt)}
408 </div>
409 </div>
410 </section>
411
412 {/* Recent findings */}
413 <section class="adv-scan-section">
414 <header class="adv-scan-section-head">
415 <div class="adv-scan-section-head-text">
416 <h3 class="adv-scan-section-title">Recent findings</h3>
417 <p class="adv-scan-section-sub">
418 Last 25 issues opened on{" "}
419 <code>
420 {process.env.SELF_HOST_REPO ||
421 ADVANCEMENT_DEFAULT_SELF_HOST_REPO}
422 </code>{" "}
423 under the <code>{ADVANCEMENT_LABEL_NAME}</code> label.
424 </p>
425 </div>
426 <form
427 action="/admin/advancement/run"
428 method="post"
429 class="adv-scan-runform"
430 >
431 <button type="submit" class="adv-scan-btn adv-scan-btn-primary">
432 <IconPlay />
433 Run scan now
434 </button>
435 </form>
436 </header>
437 <div class="adv-scan-section-body">
438 {!repo ? (
439 <div class="adv-scan-empty">
440 Self-host repo not resolved yet — push the platform to itself
441 or set <code>SELF_HOST_REPO</code> to the owner/name pair.
442 </div>
443 ) : recent.length === 0 ? (
444 <div class="adv-scan-empty">
445 No advancement findings yet. The scanner runs weekly on
446 Mondays at 08:00 UTC — kick one manually with the button
447 above to seed this list.
448 </div>
449 ) : (
450 <ol class="adv-scan-list" aria-label="Recent advancement findings">
451 {recent.map((r) => {
452 const urgencyClass =
453 r.urgency.toLowerCase().startsWith("high")
454 ? "is-high"
455 : r.urgency.toLowerCase().startsWith("med")
456 ? "is-medium"
457 : "is-low";
458 const closed = r.state === "closed";
459 return (
460 <li class={"adv-scan-row " + (closed ? "is-closed" : "")}>
461 <div class="adv-scan-row-head">
462 <span
463 class={"adv-scan-urgency " + urgencyClass}
464 aria-label={`urgency ${r.urgency}`}
465 >
466 {r.urgency}
467 </span>
468 <span class="adv-scan-kind">{r.kind}</span>
469 <span
470 class="adv-scan-row-when adv-scan-tabular"
471 title={r.createdAt.toISOString()}
472 >
473 {fmtAgo(r.createdAt)}
474 </span>
475 </div>
476 <div class="adv-scan-row-title">
477 {repo ? (
478 <a
479 class="adv-scan-row-link"
480 href={`/${repo.ownerName}/${repo.repoName}/issues/${r.issueNumber}`}
481 >
482 {r.title}
483 </a>
484 ) : (
485 <span>{r.title}</span>
486 )}
487 </div>
488 <div class="adv-scan-row-actions">
489 {!closed && (
490 <form
491 method="post"
492 action={`/admin/advancement/dismiss/${r.issueId}`}
493 class="adv-scan-actform"
494 >
495 <button type="submit" class="adv-scan-btn adv-scan-btn-ghost">
496 Dismiss
497 </button>
498 </form>
499 )}
500 {closed && (
501 <span class="adv-scan-tag-closed">Closed</span>
502 )}
503 </div>
504 </li>
505 );
506 })}
507 </ol>
508 )}
509 </div>
510 </section>
511
512 {/* Settings */}
513 <section class="adv-scan-section">
514 <header class="adv-scan-section-head">
515 <div class="adv-scan-section-head-text">
516 <h3 class="adv-scan-section-title">Settings</h3>
517 <p class="adv-scan-section-sub">
518 Toggle whether the autopilot runs the weekly scan. Stored in{" "}
519 <code>system_config</code> so changes apply without a restart.
520 </p>
521 </div>
522 </header>
523 <div class="adv-scan-section-body">
524 <form
525 action="/admin/advancement/settings"
526 method="post"
527 class="adv-scan-settings"
528 >
529 <label class="adv-scan-toggle">
530 <input
531 type="checkbox"
532 name="enabled"
533 value="1"
534 checked={enabled}
535 />
536 <span class="adv-scan-toggle-slider" aria-hidden="true" />
537 <span class="adv-scan-toggle-label">
538 Enable weekly advancement scan
539 </span>
540 </label>
541 <button type="submit" class="adv-scan-btn adv-scan-btn-primary">
542 Save
543 </button>
544 </form>
545 </div>
546 </section>
547 </div>
548 <style dangerouslySetInnerHTML={{ __html: ADV_SCAN_CSS }} />
549 </Layout>
550 );
551});
552
553// ---------------------------------------------------------------------------
554// POST /admin/advancement/run — fire a scan synchronously
555// ---------------------------------------------------------------------------
556
557advancement.post("/admin/advancement/run", async (c) => {
558 const g = await gate(c);
559 if (g instanceof Response) return g;
560 const { user } = g;
561 try {
562 const result = await runAdvancementScan();
563 await audit({
564 userId: user.id,
565 action: "admin.advancement.run",
566 metadata: {
567 findings: result.findings.length,
568 openedIssues: result.openedIssues,
569 openedPrs: result.openedPrs,
570 },
571 });
572 return redirectWith(
573 c,
574 "success",
575 `Scan complete — ${result.findings.length} finding${
576 result.findings.length === 1 ? "" : "s"
577 }, ${result.openedIssues} new issue${
578 result.openedIssues === 1 ? "" : "s"
579 }, ${result.openedPrs} new PR${result.openedPrs === 1 ? "" : "s"}.`
580 );
581 } catch (err) {
582 const m = err instanceof Error ? err.message : String(err);
583 return redirectWith(c, "error", `Scan failed: ${m}`);
584 }
585});
586
587// ---------------------------------------------------------------------------
588// POST /admin/advancement/settings — flip the enabled toggle
589// ---------------------------------------------------------------------------
590
591advancement.post("/admin/advancement/settings", async (c) => {
592 const g = await gate(c);
593 if (g instanceof Response) return g;
594 const { user } = g;
595 const form = await c.req.formData();
596 const enabled = form.get("enabled") === "1";
597 try {
598 await setConfigValue(ENABLED_CONFIG_KEY, enabled ? "1" : "0", user.id);
599 return redirectWith(
600 c,
601 "success",
602 `Weekly scan ${enabled ? "enabled" : "disabled"}.`
603 );
604 } catch (err) {
605 const m = err instanceof Error ? err.message : String(err);
606 return redirectWith(c, "error", `Could not save setting: ${m}`);
607 }
608});
609
610// ---------------------------------------------------------------------------
611// POST /admin/advancement/dismiss/:id — close the finding issue
612// ---------------------------------------------------------------------------
613
614advancement.post("/admin/advancement/dismiss/:id", async (c) => {
615 const g = await gate(c);
616 if (g instanceof Response) return g;
617 const { user } = g;
618 const id = c.req.param("id");
619 if (!id) return redirectWith(c, "error", "Missing finding id.");
620 try {
621 await db
622 .update(issues)
623 .set({ state: "closed", closedAt: new Date() })
624 .where(eq(issues.id, id));
625 await audit({
626 userId: user.id,
627 action: "admin.advancement.dismiss",
628 targetType: "issue",
629 targetId: id,
630 });
631 return redirectWith(c, "success", "Finding dismissed.");
632 } catch (err) {
633 const m = err instanceof Error ? err.message : String(err);
634 return redirectWith(c, "error", `Dismiss failed: ${m}`);
635 }
636});
637
638// ---------------------------------------------------------------------------
639// Scoped CSS — every class prefixed `.adv-scan-` so this page can't bleed
640// into the shared layout / other admin surfaces.
641// ---------------------------------------------------------------------------
642
643const ADV_SCAN_CSS = `
644 .adv-scan-wrap {
645 max-width: 1100px;
646 margin: 0 auto;
647 padding: var(--space-6) var(--space-4) var(--space-12);
648 }
649 .adv-scan-tabular { font-variant-numeric: tabular-nums; }
650
651 /* ─── Hero ─── */
652 .adv-scan-hero {
653 position: relative;
654 margin-bottom: var(--space-5);
655 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
656 background: var(--bg-elevated);
657 border: 1px solid var(--border);
658 border-radius: 18px;
659 overflow: hidden;
660 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
661 }
662 .adv-scan-hero::before {
663 content: '';
664 position: absolute;
665 top: 0; left: 0; right: 0;
666 height: 2px;
6fd5915Claude667 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
d199847Claude668 opacity: 0.75;
669 pointer-events: none;
670 }
671 .adv-scan-hero-orb {
672 position: absolute;
673 inset: -30% -10% auto auto;
674 width: 460px; height: 460px;
6fd5915Claude675 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
d199847Claude676 filter: blur(80px);
677 opacity: 0.7;
678 pointer-events: none;
679 z-index: 0;
680 }
681 .adv-scan-hero-inner { position: relative; z-index: 1; }
682 .adv-scan-hero-top {
683 display: flex;
684 align-items: flex-start;
685 justify-content: space-between;
686 gap: var(--space-4);
687 flex-wrap: wrap;
688 }
689 .adv-scan-hero-text { flex: 1; min-width: 280px; }
690 .adv-scan-eyebrow {
691 display: inline-flex;
692 align-items: center;
693 gap: 8px;
694 font-size: 12px;
695 color: var(--text-muted);
696 margin-bottom: 14px;
697 letter-spacing: 0.02em;
698 }
699 .adv-scan-eyebrow-pill {
700 display: inline-flex;
701 align-items: center;
702 justify-content: center;
703 width: 18px; height: 18px;
704 border-radius: 6px;
6fd5915Claude705 background: rgba(91,110,232,0.14);
706 color: #5b6ee8;
707 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
d199847Claude708 }
709 .adv-scan-eyebrow .adv-scan-who { color: var(--accent); font-weight: 600; }
710 .adv-scan-title {
711 font-family: var(--font-display);
712 font-size: clamp(28px, 4vw, 40px);
713 font-weight: 800;
714 letter-spacing: -0.028em;
715 line-height: 1.05;
716 margin: 0 0 var(--space-2);
717 color: var(--text-strong);
718 }
719 .adv-scan-title-grad {
6fd5915Claude720 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
d199847Claude721 -webkit-background-clip: text;
722 background-clip: text;
723 -webkit-text-fill-color: transparent;
724 color: transparent;
725 }
726 .adv-scan-sub {
727 font-size: 14.5px;
728 color: var(--text-muted);
729 margin: 0;
730 line-height: 1.55;
731 max-width: 700px;
732 }
733 .adv-scan-sub code {
734 font-family: var(--font-mono);
735 font-size: 12.5px;
736 background: rgba(255,255,255,0.04);
737 border: 1px solid var(--border);
738 padding: 1px 6px;
739 border-radius: 5px;
740 color: var(--text);
741 }
742 .adv-scan-hero-back {
743 display: inline-flex;
744 align-items: center;
745 gap: 6px;
746 padding: 7px 12px;
747 font-size: 12.5px;
748 color: var(--text-muted);
749 background: rgba(255,255,255,0.02);
750 border: 1px solid var(--border);
751 border-radius: 8px;
752 text-decoration: none;
753 font-weight: 500;
754 flex-shrink: 0;
755 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
756 }
757 .adv-scan-hero-back:hover {
758 border-color: var(--border-strong);
759 color: var(--text-strong);
760 background: rgba(255,255,255,0.04);
761 text-decoration: none;
762 }
763
764 /* ─── Banners ─── */
765 .adv-scan-banner {
766 margin-bottom: var(--space-4);
767 padding: 10px 14px;
768 border-radius: 10px;
769 font-size: 13.5px;
770 border: 1px solid var(--border);
771 background: rgba(255,255,255,0.025);
772 color: var(--text);
773 display: flex;
774 align-items: center;
775 gap: 10px;
776 }
777 .adv-scan-banner.is-ok {
778 border-color: rgba(52,211,153,0.40);
779 background: rgba(52,211,153,0.08);
780 color: #bbf7d0;
781 }
782 .adv-scan-banner.is-error {
783 border-color: rgba(248,113,113,0.40);
784 background: rgba(248,113,113,0.08);
785 color: #fecaca;
786 }
787 .adv-scan-banner-dot {
788 width: 8px; height: 8px;
789 border-radius: 9999px;
790 background: currentColor;
791 }
792
793 /* ─── Stat counters ─── */
794 .adv-scan-stats {
795 display: grid;
796 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
797 gap: var(--space-3);
798 margin-bottom: var(--space-5);
799 }
800 .adv-scan-stat {
801 padding: var(--space-3) var(--space-4);
802 background: var(--bg-elevated);
803 border: 1px solid var(--border);
804 border-radius: 12px;
805 }
806 .adv-scan-stat-label {
807 font-size: 11.5px;
808 text-transform: uppercase;
809 letter-spacing: 0.08em;
810 color: var(--text-muted);
811 font-weight: 600;
812 margin-bottom: 8px;
813 }
814 .adv-scan-stat-value {
815 font-family: var(--font-display);
816 font-size: 26px;
817 font-weight: 800;
818 color: var(--text-strong);
819 letter-spacing: -0.02em;
820 }
821
822 /* ─── Section card ─── */
823 .adv-scan-section {
824 margin-bottom: var(--space-5);
825 background: var(--bg-elevated);
826 border: 1px solid var(--border);
827 border-radius: 14px;
828 overflow: hidden;
829 }
830 .adv-scan-section-head {
831 padding: var(--space-4);
832 display: flex;
833 align-items: flex-start;
834 justify-content: space-between;
835 gap: var(--space-3);
836 border-bottom: 1px solid var(--border);
837 flex-wrap: wrap;
838 }
839 .adv-scan-section-head-text { flex: 1; min-width: 240px; }
840 .adv-scan-section-title {
841 font-size: 16px;
842 font-weight: 700;
843 margin: 0 0 4px;
844 color: var(--text-strong);
845 }
846 .adv-scan-section-sub {
847 font-size: 13px;
848 color: var(--text-muted);
849 margin: 0;
850 line-height: 1.5;
851 }
852 .adv-scan-section-sub code {
853 font-family: var(--font-mono);
854 font-size: 12px;
855 background: rgba(255,255,255,0.04);
856 border: 1px solid var(--border);
857 padding: 1px 6px;
858 border-radius: 5px;
859 color: var(--text);
860 }
861 .adv-scan-section-body {
862 padding: var(--space-3) var(--space-4) var(--space-4);
863 }
864 .adv-scan-empty {
865 padding: var(--space-5);
866 text-align: center;
867 color: var(--text-muted);
868 font-size: 13.5px;
869 }
870 .adv-scan-empty code {
871 font-family: var(--font-mono);
872 font-size: 12px;
873 color: var(--text);
874 }
875
876 /* ─── Findings list ─── */
877 .adv-scan-list {
878 list-style: none;
879 padding: 0;
880 margin: 0;
881 display: flex;
882 flex-direction: column;
883 gap: var(--space-2);
884 }
885 .adv-scan-row {
886 padding: var(--space-3);
887 border: 1px solid var(--border);
888 border-radius: 10px;
889 background: rgba(255,255,255,0.012);
890 display: flex;
891 flex-direction: column;
892 gap: 6px;
893 }
894 .adv-scan-row.is-closed { opacity: 0.62; }
895 .adv-scan-row-head {
896 display: flex;
897 align-items: center;
898 gap: 10px;
899 flex-wrap: wrap;
900 }
901 .adv-scan-urgency {
902 font-size: 10.5px;
903 font-weight: 700;
904 text-transform: uppercase;
905 letter-spacing: 0.08em;
906 padding: 3px 8px;
907 border-radius: 9999px;
908 }
909 .adv-scan-urgency.is-high {
910 background: rgba(248,113,113,0.12);
911 color: #fca5a5;
912 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
913 }
914 .adv-scan-urgency.is-medium {
915 background: rgba(251,191,36,0.12);
916 color: #fcd34d;
917 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
918 }
919 .adv-scan-urgency.is-low {
920 background: rgba(148,163,184,0.10);
921 color: #cbd5e1;
922 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.28);
923 }
924 .adv-scan-kind {
925 font-family: var(--font-mono);
926 font-size: 11.5px;
927 color: var(--text-muted);
928 }
929 .adv-scan-row-when {
930 margin-left: auto;
931 font-size: 11.5px;
932 color: var(--text-muted);
933 }
934 .adv-scan-row-title {
935 font-size: 14px;
936 line-height: 1.4;
937 color: var(--text);
938 }
939 .adv-scan-row-link {
940 color: var(--text-strong);
941 text-decoration: none;
942 border-bottom: 1px dotted transparent;
943 }
944 .adv-scan-row-link:hover {
945 border-bottom-color: var(--accent);
946 color: var(--accent);
947 }
948 .adv-scan-row-actions {
949 display: flex;
950 align-items: center;
951 gap: 8px;
952 margin-top: 4px;
953 }
954 .adv-scan-actform { display: inline; }
955 .adv-scan-tag-closed {
956 font-size: 11px;
957 font-weight: 600;
958 text-transform: uppercase;
959 letter-spacing: 0.08em;
960 color: var(--text-muted);
961 }
962
963 /* ─── Buttons ─── */
964 .adv-scan-btn {
965 display: inline-flex;
966 align-items: center;
967 gap: 6px;
968 padding: 7px 14px;
969 font-size: 12.5px;
970 font-weight: 600;
971 border-radius: 8px;
972 border: 1px solid var(--border);
973 background: rgba(255,255,255,0.02);
974 color: var(--text);
975 cursor: pointer;
976 text-decoration: none;
977 font-family: inherit;
978 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
979 }
980 .adv-scan-btn:hover {
981 border-color: var(--border-strong);
982 background: rgba(255,255,255,0.05);
983 color: var(--text-strong);
984 }
985 .adv-scan-btn-primary {
6fd5915Claude986 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
d199847Claude987 color: #fff;
988 border-color: transparent;
6fd5915Claude989 box-shadow: 0 1px 0 rgba(255,255,255,0.10), 0 8px 22px -10px rgba(91,110,232,0.55);
d199847Claude990 }
991 .adv-scan-btn-primary:hover {
992 color: #fff;
993 border-color: transparent;
994 filter: brightness(1.08);
995 }
996 .adv-scan-btn-ghost {
997 background: transparent;
998 }
999 .adv-scan-runform { display: inline; }
1000
1001 /* ─── Settings card ─── */
1002 .adv-scan-settings {
1003 display: flex;
1004 align-items: center;
1005 justify-content: space-between;
1006 gap: var(--space-3);
1007 flex-wrap: wrap;
1008 }
1009 .adv-scan-toggle {
1010 display: inline-flex;
1011 align-items: center;
1012 gap: 12px;
1013 cursor: pointer;
1014 font-size: 14px;
1015 color: var(--text);
1016 }
1017 .adv-scan-toggle input { position: absolute; opacity: 0; pointer-events: none; }
1018 .adv-scan-toggle-slider {
1019 position: relative;
1020 width: 38px;
1021 height: 22px;
1022 background: rgba(255,255,255,0.08);
1023 border-radius: 9999px;
1024 transition: background 120ms ease;
1025 flex-shrink: 0;
1026 }
1027 .adv-scan-toggle-slider::after {
1028 content: '';
1029 position: absolute;
1030 top: 3px; left: 3px;
1031 width: 16px; height: 16px;
1032 background: var(--text-strong);
1033 border-radius: 9999px;
1034 transition: transform 120ms ease;
1035 }
1036 .adv-scan-toggle input:checked + .adv-scan-toggle-slider {
6fd5915Claude1037 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
d199847Claude1038 }
1039 .adv-scan-toggle input:checked + .adv-scan-toggle-slider::after {
1040 transform: translateX(16px);
1041 }
1042 .adv-scan-toggle-label { user-select: none; }
1043
1044 /* ─── 403 fallback ─── */
1045 .adv-scan-403 {
1046 max-width: 480px;
1047 margin: 80px auto;
1048 text-align: center;
1049 color: var(--text-muted);
1050 }
1051`;
1052
1053export default advancement;
1054
1055export const __test = {
1056 extractFromBody,
1057 loadStats,
1058 loadRecentFindings,
1059 resolveSelfHostRepoUi,
1060};