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

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