CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(design): the four trust-critical pages — honest states, legible status, house palette #5540

Merged⚡ AI-generatedXSccantynz wants to mergefix/trust-critical-pagesmainopened 6d ago
7 changed files+400−164
Addedsrc/__tests__/bus-factor-zero-scan.test.tsx+80−0View fileUnifiedSplit
1/**
2 * Bus-factor zero-scan honesty — design sweep 2026-08-26, CRITICAL #3.
3 *
4 * A cached report with `totalFilesAnalyzed: 0` used to render the green
5 * "No knowledge concentration detected / All analyzed files have healthy
6 * authorship spread" all-clear — vacuously true over zero files, which is
7 * the partial-coverage-as-complete trap on a risk surface. Zero-scan must
8 * render as its own honest "No analysis yet" state: never green, never a
9 * risk verdict in either direction.
10 *
11 * `BusFactorResults` is a pure component (report → HTML), so it is rendered
12 * directly without a router or DB.
13 */
14
15import { describe, it, expect } from "bun:test";
16import { BusFactorResults } from "../routes/bus-factor";
17import type { BusFactorReport } from "../lib/bus-factor";
18
19function render(report: BusFactorReport): string {
20 return String(<BusFactorResults report={report} />);
21}
22
23const base = {
24 repoId: "r-1",
25 analyzedAt: "2026-08-26T00:00:00.000Z",
26};
27
28describe("BusFactorResults — zero files scanned", () => {
29 const html = render({ ...base, atRiskFiles: [], totalFilesAnalyzed: 0 });
30
31 it("does not render the all-clear markup", () => {
32 expect(html).not.toContain("No knowledge concentration detected");
33 expect(html).not.toContain("healthy authorship spread");
34 // The green check-mark empty state is the all-clear's visual signature.
35 expect(html).not.toContain(">✓<");
36 });
37
38 it("renders the honest 'No analysis yet' state instead", () => {
39 expect(html).toContain("No analysis yet");
40 expect(html).toContain("has not been assessed");
41 // The copy states the real scan predicate (src/lib/bus-factor.ts:
42 // totalCommits < 3 files are skipped from authorship judgment).
43 expect(html).toContain("at least");
44 expect(html).toContain("3 commits");
45 });
46
47 it("does not render the risk stat cards over an empty scan", () => {
48 expect(html).not.toContain("Critical risk files");
49 expect(html).not.toContain("Total at-risk files");
50 });
51});
52
53describe("BusFactorResults — non-empty scan keeps the real states", () => {
54 it("clean scan over real files still shows the all-clear, with the count", () => {
55 const html = render({ ...base, atRiskFiles: [], totalFilesAnalyzed: 42 });
56 expect(html).toContain("No knowledge concentration detected");
57 expect(html).toContain("42");
58 expect(html).not.toContain("No analysis yet");
59 });
60
61 it("at-risk files render the risk list, not the all-clear", () => {
62 const html = render({
63 ...base,
64 totalFilesAnalyzed: 10,
65 atRiskFiles: [
66 {
67 path: "src/lib/one-owner.ts",
68 primaryAuthor: "alice",
69 primaryAuthorPct: 92,
70 totalCommits: 8,
71 lastModified: "2026-08-20",
72 risk: "critical",
73 },
74 ],
75 });
76 expect(html).toContain("src/lib/one-owner.ts");
77 expect(html).toContain("critical");
78 expect(html).not.toContain("No knowledge concentration detected");
79 });
80});
Modifiedsrc/routes/auth.tsx+4−0View fileUnifiedSplit
473473 error={error ? decodeURIComponent(error) : ""}
474474 googleEnabled={googleEnabled}
475475 githubEnabled={githubEnabled}
476 // Additive: the standalone sign-in document renders its own <html>,
477 // so it needs the theme cookie resolved here the way layout.tsx pages
478 // get it — without this, dark-mode users hit a hardcoded-light page.
479 theme={getCookie(c, "theme") === "dark" ? "dark" : "light"}
476480 />
477481 );
478482});
Modifiedsrc/routes/bus-factor.tsx+102−65View fileUnifiedSplit
278278 }
279279`;
280280
281// ─── Results body (pure, exported for tests) ─────────────────────────────────
282
283/**
284 * Renders the analysis results for a completed scan. Exported so the
285 * zero-scan honesty rule can be pinned by a unit test without a DB:
286 * `totalFilesAnalyzed === 0` must NEVER produce the green all-clear —
287 * "all analyzed files are healthy" is vacuously true over zero files, and
288 * rendering it as a pass is the partial-coverage-as-complete trap on a
289 * risk surface (design sweep 2026-08-26, CRITICAL #3).
290 *
291 * The scan predicate this copy describes (src/lib/bus-factor.ts): a file
292 * enters the analysis when it appears as a code file in the repository's
293 * commit history (last 5000 commits), and authorship spread is only judged
294 * once a file has at least 3 commits.
295 */
296export function BusFactorResults({ report }: { report: BusFactorReport }) {
297 if (report.totalFilesAnalyzed === 0) {
298 return (
299 <div class="bf-empty">
300 <div class="bf-empty-icon">🕐</div>
301 <div class="bf-empty-title">No analysis yet</div>
302 <p class="bf-empty-sub">
303 The last scan found no code files in this repository's commit
304 history, so authorship spread has not been assessed. A file enters
305 the analysis once it appears in commit history and needs at least
306 3 commits before its authorship spread can be judged.
307 </p>
308 </div>
309 );
310 }
311
312 const criticalCount = report.atRiskFiles.filter((f) => f.risk === "critical").length;
313 const highCount = report.atRiskFiles.filter((f) => f.risk === "high").length;
314 const mediumCount = report.atRiskFiles.filter((f) => f.risk === "medium").length;
315
316 return (
317 <>
318 {/* Stats */}
319 <div class="bf-stats">
320 <div class="bf-stat-card is-critical">
321 <div class="bf-stat-value">{criticalCount}</div>
322 <div class="bf-stat-label">Critical risk files</div>
323 </div>
324 <div class="bf-stat-card is-high">
325 <div class="bf-stat-value">{highCount}</div>
326 <div class="bf-stat-label">High risk files</div>
327 </div>
328 <div class="bf-stat-card is-medium">
329 <div class="bf-stat-value">{mediumCount}</div>
330 <div class="bf-stat-label">Medium risk files</div>
331 </div>
332 <div class="bf-stat-card">
333 <div class="bf-stat-value">{report.atRiskFiles.length}</div>
334 <div class="bf-stat-label">Total at-risk files</div>
335 </div>
336 </div>
337
338 {/* File list */}
339 {report.atRiskFiles.length === 0 ? (
340 <div class="bf-empty">
341 <div class="bf-empty-icon">✓</div>
342 <div class="bf-empty-title">No knowledge concentration detected</div>
343 <p class="bf-empty-sub">
344 All {report.totalFilesAnalyzed} analyzed files have healthy
345 authorship spread.
346 </p>
347 </div>
348 ) : (
349 <div class="bf-list">
350 {report.atRiskFiles.map((file) => (
351 <div class={`bf-file-card risk-${file.risk}`}>
352 <div class="bf-file-header">
353 <code class="bf-file-path">{file.path}</code>
354 <span class={`bf-risk-badge is-${file.risk}`}>
355 {file.risk}
356 </span>
357 </div>
358 <div class="bf-file-meta">
359 <span>{file.totalCommits} commits</span>
360 <span>Last modified {file.lastModified}</span>
361 </div>
362 <div class="bf-bar-wrap">
363 <span class="bf-bar-label" title={file.primaryAuthor}>
364 {file.primaryAuthor}
365 </span>
366 <div class="bf-bar-track">
367 <div
368 class="bf-bar-fill"
369 style={`width:${file.primaryAuthorPct}%`}
370 />
371 </div>
372 <span class="bf-bar-pct">{file.primaryAuthorPct}%</span>
373 </div>
374 </div>
375 ))}
376 </div>
377 )}
378 </>
379 );
380}
381
281382// ─── Route: GET /:owner/:repo/insights/bus-factor ─────────────────────────────
282383
283384busFactorRoutes.use("/:owner/:repo/insights/bus-factor", softAuth);
320421 };
321422 }
322423
323 const criticalCount = report?.atRiskFiles.filter((f) => f.risk === "critical").length ?? 0;
324 const highCount = report?.atRiskFiles.filter((f) => f.risk === "high").length ?? 0;
325 const mediumCount = report?.atRiskFiles.filter((f) => f.risk === "medium").length ?? 0;
326
327424 return c.html(
328425 <Layout
329426 title={`Bus Factor — ${ownerName}/${repoName}`}
373470 </div>
374471
375472 {report ? (
376 <>
377 {/* Stats */}
378 <div class="bf-stats">
379 <div class="bf-stat-card is-critical">
380 <div class="bf-stat-value">{criticalCount}</div>
381 <div class="bf-stat-label">Critical risk files</div>
382 </div>
383 <div class="bf-stat-card is-high">
384 <div class="bf-stat-value">{highCount}</div>
385 <div class="bf-stat-label">High risk files</div>
386 </div>
387 <div class="bf-stat-card is-medium">
388 <div class="bf-stat-value">{mediumCount}</div>
389 <div class="bf-stat-label">Medium risk files</div>
390 </div>
391 <div class="bf-stat-card">
392 <div class="bf-stat-value">{report.atRiskFiles.length}</div>
393 <div class="bf-stat-label">Total at-risk files</div>
394 </div>
395 </div>
396
397 {/* File list */}
398 {report.atRiskFiles.length === 0 ? (
399 <div class="bf-empty">
400 <div class="bf-empty-icon"></div>
401 <div class="bf-empty-title">No knowledge concentration detected</div>
402 <p class="bf-empty-sub">
403 All analyzed files have healthy authorship spread.
404 </p>
405 </div>
406 ) : (
407 <div class="bf-list">
408 {report.atRiskFiles.map((file) => (
409 <div class={`bf-file-card risk-${file.risk}`}>
410 <div class="bf-file-header">
411 <code class="bf-file-path">{file.path}</code>
412 <span class={`bf-risk-badge is-${file.risk}`}>
413 {file.risk}
414 </span>
415 </div>
416 <div class="bf-file-meta">
417 <span>{file.totalCommits} commits</span>
418 <span>Last modified {file.lastModified}</span>
419 </div>
420 <div class="bf-bar-wrap">
421 <span class="bf-bar-label" title={file.primaryAuthor}>
422 {file.primaryAuthor}
423 </span>
424 <div class="bf-bar-track">
425 <div
426 class="bf-bar-fill"
427 style={`width:${file.primaryAuthorPct}%`}
428 />
429 </div>
430 <span class="bf-bar-pct">{file.primaryAuthorPct}%</span>
431 </div>
432 </div>
433 ))}
434 </div>
435 )}
436 </>
473 <BusFactorResults report={report} />
437474 ) : (
438475 <div class="bf-empty">
439476 <div class="bf-empty-icon">📊</div>
Modifiedsrc/routes/dora.tsx+73−13View fileUnifiedSplit
245245 .dora-pill-failed { background: var(--red, #f44336); }
246246 .dora-pill-other { background: var(--text-muted); }
247247 .dora-sha { font-family: monospace; font-size: 12px; color: var(--text-muted); }
248
249 /* Honest neutral state — deploy tracking not wired for this repo. Never a
250 judgment color: this is an absence of instrumentation, not a result. */
251 .dora-unwired {
252 padding: 16px 20px;
253 background: var(--bg-secondary);
254 border: 1px dashed var(--border-strong);
255 border-radius: 12px;
256 font-size: 13.5px;
257 color: var(--text-muted);
258 line-height: 1.55;
259 }
260 .dora-unwired strong { color: var(--text); font-weight: 600; }
261 .dora-overall-none {
262 font-size: 13px;
263 color: var(--text-muted);
264 }
248265`;
249266
250267// ─── Route ────────────────────────────────────────────────────────────────────
365382 })(),
366383 ]);
367384
385 // ─── Deploy tracking wiring check (Zero-Noise) ────────────────────────
386 // The `deployments` table is populated by the deploy webhook path only
387 // (post-receive BLK-016 / the Vapron deploy hook). Self-hosted repos —
388 // including this platform itself, which ships via the gluecron-update
389 // timer — never write rows there, so "0 deployments" out of this table
390 // is a fact about the wiring, not about the repo's deploy cadence.
391 // Zero rows ALL-TIME therefore renders as "not wired", never as a red
392 // "Low / 0.0 per week" verdict derived from an unwired source.
393 // (`platform_deploys`, which /admin/deploys reads, is not repo-scoped,
394 // so it cannot stand in as a per-repo proxy here.)
395 // true → rows exist, metrics are computable and honest
396 // false → zero rows ever: tracking is not wired for this repo
397 // null → DB error: unknown (cards keep their "No data" state)
398 const deployTrackingWired: boolean | null =
399 last50Deployments === null ? null : last50Deployments.length > 0;
400
368401 // ─── Metric 1: Deployment frequency ──────────────────────────────────
369402 let deploysPerWeek: number | null = null;
370403 let freqLevel: DoraLevel | null = null;
371 if (recentDeployments !== null) {
404 if (recentDeployments !== null && deployTrackingWired === true) {
372405 const count = recentDeployments.length;
373406 deploysPerWeek = (count / 30) * 7;
374407 freqLevel = deployFreqLevel(deploysPerWeek);
444477 }
445478
446479 // ─── Overall DORA level (worst of the 4 core metrics) ─────────────────
447 const overallLevel = worstLevel([freqLevel, leadLevel, failureLevel, mttrLvl]);
480 // Only meaningful when at least one core metric was actually measured —
481 // worstLevel() of four nulls returns "Elite", which would be a false
482 // green all-clear on a repo with no deploy tracking at all.
483 const coreLevels = [freqLevel, leadLevel, failureLevel, mttrLvl];
484 const hasAnyCoreLevel = coreLevels.some((l) => l !== null);
485 const overallLevel = worstLevel(coreLevels);
448486
449487 // ─── Last 10 deployments for the table ────────────────────────────────
450488 const last10 = recentDeployments ? recentDeployments.slice(0, 10) : [];
451489
490 // Card fallback label for the four deployment-derived metrics: "Not
491 // tracked" when the source is unwired (honest absence), "No data" only
492 // for a genuine gap (DB error, or wired but too few rows to compute).
493 const deployNa = deployTrackingWired === false ? "Not tracked" : "No data";
494
452495 // ─── Render ──────────────────────────────────────────────────────────
453496 return c.html(
454497 <Layout title={`DORA Metrics — ${owner}/${repo}`} user={user}>
466509 </p>
467510 <div class="dora-overall">
468511 <span class="dora-overall-label">Overall DORA Level</span>
469 <span
470 class="dora-overall-badge"
471 style={`background:${levelColor(overallLevel)}`}
472 >
473 {overallLevel}
474 </span>
512 {hasAnyCoreLevel ? (
513 <span
514 class="dora-overall-badge"
515 style={`background:${levelColor(overallLevel)}`}
516 >
517 {overallLevel}
518 </span>
519 ) : (
520 <span class="dora-overall-none">
521 {deployTrackingWired === false
522 ? "Not measured — deploy tracking is not wired for this repository"
523 : "Not measured — deployment data is unavailable right now"}
524 </span>
525 )}
475526 </div>
476527 </div>
477528
494545 </div>
495546 </>
496547 ) : (
497 <div class="dora-card-value dora-na">No data</div>
548 <div class="dora-card-value dora-na">{deployNa}</div>
498549 )}
499550 </div>
500551
515566 </div>
516567 </>
517568 ) : (
518 <div class="dora-card-value dora-na">No data</div>
569 <div class="dora-card-value dora-na">{deployNa}</div>
519570 )}
520571 </div>
521572
536587 </div>
537588 </>
538589 ) : (
539 <div class="dora-card-value dora-na">No data</div>
590 <div class="dora-card-value dora-na">{deployNa}</div>
540591 )}
541592 </div>
542593
557608 </div>
558609 </>
559610 ) : (
560 <div class="dora-card-value dora-na">No data</div>
611 <div class="dora-card-value dora-na">{deployNa}</div>
561612 )}
562613 </div>
563614
594645
595646 {/* Last 10 deployments table */}
596647 <h2 class="dora-section-title">Last 10 Deployments</h2>
597 {last10.length === 0 ? (
648 {deployTrackingWired === false ? (
649 <div class="dora-unwired">
650 <strong>Deploy tracking is not wired for this repository.</strong>{" "}
651 These metrics read the deployments table, which is populated by
652 the deploy webhook — repositories that ship through another path
653 (like this platform's own update timer) never record rows there,
654 so no deployment-based metric can be measured yet. Gate and
655 workflow rates above come from their own sources and are real.
656 </div>
657 ) : last10.length === 0 ? (
598658 <p style="color:var(--text-muted);font-size:14px;">No deployments found in the last 30 days.</p>
599659 ) : (
600660 <div class="dora-table-wrap">
Modifiedsrc/routes/nl-search.tsx+30−26View fileUnifiedSplit
5151 font-weight: 600;
5252 margin-bottom: 10px;
5353 }
54 /* Re-paletted 2026-08-26: this page was a wholesale orange/red takeover
55 (design sweep, MAJOR #8) — every accent below now speaks the house
56 evergreen via tokens, matching the other hero surfaces. */
5457 .nl-eyebrow-dot {
5558 width: 8px; height: 8px;
5659 border-radius: 9999px;
57 background: linear-gradient(135deg, #f59e0b, #ef4444);
58 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
60 background: linear-gradient(135deg, var(--accent), var(--accent-2));
61 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
5962 }
6063 .nl-title {
6164 font-family: var(--font-display);
6770 color: var(--text-strong);
6871 }
6972 .nl-title-grad {
70 background-image: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #ef4444 100%);
73 background-image: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
7174 -webkit-background-clip: text;
7275 background-clip: text;
7376 -webkit-text-fill-color: transparent;
9699 .nl-provider .dot {
97100 width: 6px; height: 6px;
98101 border-radius: 9999px;
99 background: linear-gradient(135deg, #f59e0b, #ef4444);
102 background: linear-gradient(135deg, var(--accent), var(--accent-2));
100103 }
101104
102105 /* ─── Search bar ─── */
129132 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
130133 }
131134 .nl-search-input:focus {
132 border-color: rgba(245,158,11,0.55);
133 background: rgba(255,255,255,0.05);
134 box-shadow: 0 0 0 3px rgba(245,158,11,0.20);
135 border-color: var(--border-focus);
136 background: var(--bg-elevated);
137 box-shadow: var(--ring);
135138 }
136139 .nl-btn {
137140 display: inline-flex;
151154 white-space: nowrap;
152155 }
153156 .nl-btn-primary {
154 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
157 background: var(--accent);
155158 color: #ffffff;
156 box-shadow: 0 6px 18px -6px rgba(245,158,11,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
159 box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 1px 2px rgba(0,0,0,0.25);
157160 }
158161 .nl-btn-primary:hover {
162 background: var(--accent-hover);
159163 transform: translateY(-1px);
160 box-shadow: 0 10px 24px -8px rgba(245,158,11,0.65), inset 0 1px 0 rgba(255,255,255,0.20);
164 box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 4px 12px rgba(0,0,0,0.25);
161165 text-decoration: none;
162166 color: #ffffff;
163167 }
189193 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
190194 }
191195 .nl-example-chip:hover {
192 border-color: rgba(245,158,11,0.45);
196 border-color: color-mix(in srgb, var(--accent) 45%, transparent);
193197 color: var(--text-strong);
194 background: rgba(245,158,11,0.06);
198 background: color-mix(in srgb, var(--accent) 7%, transparent);
195199 text-decoration: none;
196200 }
197201
244248 letter-spacing: -0.005em;
245249 }
246250 .nl-result-path .lines { color: var(--text-muted); font-weight: 500; }
247 .nl-result-path:hover { color: #fcd34d; text-decoration: none; }
251 .nl-result-path:hover { color: var(--accent); text-decoration: none; }
248252 .nl-confidence {
249253 display: inline-flex;
250254 align-items: center;
257261 flex-shrink: 0;
258262 }
259263 .nl-confidence-high {
260 background: rgba(52,211,153,0.12);
261 color: #6ee7b7;
262 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
264 background: color-mix(in srgb, var(--green) 12%, transparent);
265 color: var(--green);
266 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green) 30%, transparent);
263267 }
264268 .nl-confidence-medium {
265 background: rgba(251,191,36,0.12);
266 color: #fcd34d;
267 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
269 background: color-mix(in srgb, var(--amber) 12%, transparent);
270 color: var(--amber);
271 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--amber) 30%, transparent);
268272 }
269273 .nl-confidence-low {
270274 background: rgba(156,163,175,0.10);
298302 padding: 12px 16px;
299303 border-radius: 10px;
300304 font-size: 13.5px;
301 border: 1px solid rgba(245,158,11,0.35);
302 background: rgba(245,158,11,0.08);
303 color: #fcd34d;
305 border: 1px solid color-mix(in srgb, var(--amber) 35%, transparent);
306 background: color-mix(in srgb, var(--amber) 9%, var(--bg-elevated));
307 color: var(--amber);
304308 display: flex;
305309 align-items: center;
306310 gap: 10px;
321325 position: absolute;
322326 inset: -40% 25% auto 25%;
323327 height: 300px;
324 background: radial-gradient(circle, rgba(245,158,11,0.15), rgba(239,68,68,0.08) 45%, transparent 70%);
328 background: radial-gradient(circle, color-mix(in srgb, var(--accent) 15%, transparent), color-mix(in srgb, var(--accent-2) 8%, transparent) 45%, transparent 70%);
325329 filter: blur(72px);
326330 opacity: 0.7;
327331 pointer-events: none;
332336 width: 56px; height: 56px;
333337 margin: 0 auto 14px;
334338 border-radius: 9999px;
335 background: linear-gradient(135deg, rgba(245,158,11,0.25), rgba(239,68,68,0.20));
336 box-shadow: inset 0 0 0 1px rgba(245,158,11,0.40);
339 background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 22%, transparent), color-mix(in srgb, var(--accent-2) 18%, transparent));
340 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
337341 display: inline-flex;
338342 align-items: center;
339343 justify-content: center;
340 color: #fcd34d;
344 color: var(--accent);
341345 }
342346 .nl-empty-title {
343347 font-family: var(--font-display);
Modifiedsrc/routes/security.tsx+14−9View fileUnifiedSplit
149149 .svp-count-card.is-low .svp-count-value { color: #a5b4fc; }
150150 .svp-count-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
151151
152 /* All-clear banner */
152 /* All-clear banner. Was pale-green literals (#bbf7d0 on a 10% green
153 tint) — legible on the dark theme it was written against, illegible
154 pale-on-pale in light mode, the default (design sweep 2026-08-26,
155 CRITICAL #4). Now the status-tint idiom digest.tsx uses: color-mix on
156 var(--green) over var(--bg-elevated), so both themes inherit a
157 readable pairing from the tokens. */
153158 .svp-clear {
154159 display: flex;
155160 align-items: center;
157162 margin-bottom: var(--space-4);
158163 padding: 16px 20px;
159164 border-radius: 14px;
160 background: linear-gradient(135deg, rgba(52,211,153,0.10), color-mix(in srgb, var(--accent-2) 6%, transparent));
161 border: 1px solid rgba(52,211,153,0.32);
162 color: #bbf7d0;
165 background: color-mix(in srgb, var(--green) 10%, var(--bg-elevated));
166 border: 1px solid color-mix(in srgb, var(--green) 38%, transparent);
167 color: var(--text);
163168 }
164169 .svp-clear-icon {
165170 flex: 0 0 auto;
168173 display: inline-flex;
169174 align-items: center;
170175 justify-content: center;
171 background: linear-gradient(135deg, #34d399 0%, var(--accent-2) 100%);
172 color: #04231a;
176 background: var(--green);
177 color: var(--bg);
173178 font-size: 18px;
174 box-shadow: 0 0 0 4px rgba(52,211,153,0.16);
179 box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 16%, transparent);
175180 }
176 .svp-clear-text strong { display: block; color: #d1fae5; font-weight: 700; font-size: 15px; margin-bottom: 2px; }
177 .svp-clear-text span { color: rgba(187,247,208,0.85); font-size: 13px; }
181 .svp-clear-text strong { display: block; color: var(--green); font-weight: 700; font-size: 15px; margin-bottom: 2px; }
182 .svp-clear-text span { color: var(--text-muted); font-size: 13px; }
178183
179184 /* Section heading */
180185 .svp-section-heading {
Modifiedsrc/views/signin-v2.tsx+97−51View fileUnifiedSplit
11import type { FC } from "hono/jsx";
22import { errorBeaconScript } from "./error-beacon";
3import { designTokensCss } from "./design-tokens";
34
45export interface SignInV2Props {
56 redirect?: string;
78 csrfToken?: string;
89 googleEnabled?: boolean;
910 githubEnabled?: boolean;
11 /** Resolved from the `theme` cookie by the /login route. Until 2026-08-26
12 this view hardcoded data-theme="light" and a literal-color stylesheet,
13 so dark-mode users got a full-brightness white page at the most-hit
14 gate in the funnel (design sweep, CRITICAL #5). */
15 theme?: "dark" | "light";
1016}
1117
1218export const SignInV2: FC<SignInV2Props> = (props) => {
1319 const { redirect = "", error = "", csrfToken, googleEnabled = true, githubEnabled = true } = props;
20 const theme = props.theme === "dark" ? "dark" : "light";
1421
1522 return (
16 <html lang="en" data-theme="light">
23 <html lang="en" data-theme={theme}>
1724 <head>
1825 <meta charset="UTF-8" />
1926 {/* This view renders outside layout.tsx, so it does not inherit the
2532 sign-in page in quirks mode with the raw URL as the tab title and
2633 no mobile scaling. It is now a complete document. */}
2734 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28 <meta name="theme-color" content="#ffffff" />
35 <meta name="theme-color" content={theme === "dark" ? "#0d1117" : "#ffffff"} />
2936 <meta name="robots" content="noindex" />
3037 <title>Sign in · gluecron</title>
3138 <link rel="icon" type="image/svg+xml" href="/icon.svg" />
32 <style dangerouslySetInnerHTML={{ __html: css }} />
39 {/* Real design tokens first (this view renders its own document, so
40 it inherits nothing from layout.tsx), then the page styles that
41 read them. */}
42 <style dangerouslySetInnerHTML={{ __html: designTokensCss + css }} />
3343 </head>
3444 <body>
3545 <div class="si-root">
7383 placeholder="Your password"
7484 required
7585 />
76 <button type="submit" class="si-btn si-btn-github si-submit">Sign in</button>
86 {/* Primary action carries the brand evergreen (house
87 btn-primary idiom) — it was riding .si-btn-github's black,
88 leaving the brand color absent from the page's main CTA. */}
89 <button type="submit" class="si-btn si-btn-primary si-submit">Sign in</button>
7790 <a href="/forgot-password" class="si-forgot">Forgot password?</a>
7891 </form>
7992
294307 layout.tsx), so the browser default body margin of 8px still applied and
295308 pushed the document 8px wider than the viewport on mobile. */
296309html, body { margin: 0; padding: 0; }
310body { background: var(--bg-secondary); }
297311
312/* Colors below read the design tokens (prepended in <head>), so the page
313 follows the theme cookie like every Layout page — the literals they
314 replace were light-only and made dark mode inert here. The left column
315 is capped so a wide desktop doesn't open a dead whitespace band between
316 the centered form and the proof panel. */
298317.si-root {
299 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
318 font-family: var(--font-sans);
300319 font-size: 14px;
301320 line-height: 1.55;
302321 letter-spacing: -0.008em;
303 color: #16181d;
304 background: #fcfcfd;
322 color: var(--text);
323 background: var(--bg-secondary);
305324 min-height: 100vh;
306325 display: grid;
307 grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
326 grid-template-columns: minmax(0, 620px) minmax(0, 1fr);
308327 -webkit-font-smoothing: antialiased;
309328 text-rendering: optimizeLegibility;
310329}
311330
331@media (max-width: 1100px) {
332 .si-root { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
333}
334
312335/* ── Left panel ── */
313336
314337.si-left {
326349 display: inline-flex;
327350 align-items: center;
328351 gap: 9px;
329 font-family: 'Inter Tight', sans-serif;
352 font-family: var(--font-display);
330353 font-weight: 600;
331354 font-size: 15px;
332355 letter-spacing: -0.02em;
333 color: #16181d;
356 color: var(--text-strong);
334357 text-decoration: none;
335358 align-self: flex-start;
336359}
357380}
358381
359382.si-heading {
360 font-family: 'Inter Tight', sans-serif;
383 font-family: var(--font-display);
361384 font-size: 26px;
362385 font-weight: 600;
363386 letter-spacing: -0.024em;
364387 line-height: 1.15;
365388 margin: 0 0 6px;
366 color: #111318;
389 color: var(--text-strong);
367390}
368391
369392.si-subheading {
370393 font-size: 13.5px;
371 color: #6b7080;
394 color: var(--text-muted);
372395 margin: 0 0 28px;
373396}
374397
375398.si-error-banner {
376 background: rgba(180, 35, 24, 0.08);
377 border: 1px solid rgba(180, 35, 24, 0.18);
399 background: color-mix(in srgb, var(--red) 9%, var(--bg-elevated));
400 border: 1px solid color-mix(in srgb, var(--red) 25%, transparent);
378401 border-radius: 10px;
379 color: #b42318;
402 color: var(--red);
380403 font-size: 13.5px;
381404 padding: 10px 14px;
382405 margin-bottom: 18px;
393416.si-field-label {
394417 font-size: 12.5px;
395418 font-weight: 600;
396 color: #3a3d47;
419 color: var(--text);
397420 margin: 10px 0 5px;
398421}
399422
405428
406429.si-forgot {
407430 font-size: 12.5px;
408 color: #6b7080;
431 color: var(--text-muted);
409432 text-decoration: none;
410433 margin-top: 12px;
411434 align-self: flex-start;
439462 letter-spacing: -0.008em;
440463}
441464
442.si-btn-github {
443 background: #16181d;
465/* Primary CTA — house btn-primary idiom: the brand evergreen on the page's
466 main action. */
467.si-btn-primary {
468 background: var(--accent);
444469 color: #fff;
445 border: 1px solid #16181d;
470 border: 1px solid transparent;
471 text-shadow: 0 1px 0 rgba(0,0,0,0.15);
472 box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 1px 2px rgba(0,0,0,0.20);
473}
474
475.si-btn-primary:hover {
476 background: var(--accent-hover);
477 box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 4px 12px rgba(0,0,0,0.20);
478}
479
480/* GitHub keeps its conventional near-ink button; expressed via tokens so it
481 inverts (light button) on the dark theme instead of vanishing into it. */
482.si-btn-github {
483 background: var(--text-strong);
484 color: var(--bg);
485 border: 1px solid transparent;
446486}
447487
448488.si-btn-github:hover {
449 box-shadow: 0 8px 22px rgba(22, 24, 29, 0.18);
489 box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
450490}
451491
452492.si-btn-secondary {
453 background: #ffffff;
454 color: #16181d;
455 border: 1px solid rgba(22, 24, 29, 0.14);
493 background: var(--bg-elevated);
494 color: var(--text);
495 border: 1px solid var(--border-strong);
456496}
457497
458498.si-btn-secondary:hover {
459 border-color: rgba(22, 24, 29, 0.30);
499 border-color: color-mix(in srgb, var(--text) 30%, transparent);
460500}
461501
462502.si-passkey-icon {
466506
467507.si-passkey-status {
468508 font-size: 12px;
469 color: #6b7080;
509 color: var(--text-muted);
470510 margin: 4px 0 0;
471511 min-height: 16px;
472512 text-align: center;
484524.si-divider-line {
485525 flex: 1;
486526 height: 1px;
487 background: rgba(22, 24, 29, 0.08);
527 background: var(--border);
488528}
489529
490530.si-divider-label {
491531 font-size: 11.5px;
492 color: #8a8d99;
493 font-family: 'JetBrains Mono', monospace;
532 color: var(--text-faint);
533 font-family: var(--font-mono);
494534 letter-spacing: 0.08em;
495535}
496536
503543
504544.si-email-input {
505545 flex: 1;
506 border: 1px solid rgba(22, 24, 29, 0.12);
546 border: 1px solid var(--border-strong);
507547 border-radius: 10px;
508548 padding: 10px 14px;
509549 outline: none;
510 font-family: 'Inter', sans-serif;
550 font-family: var(--font-sans);
511551 font-size: 14px;
512552 letter-spacing: -0.008em;
513 color: #16181d;
514 background: #ffffff;
553 color: var(--text);
554 background: var(--bg-elevated);
515555 min-width: 0;
516556 transition: border-color 0.15s ease, box-shadow 0.15s ease;
517557}
518558
519559.si-email-input:focus {
520 border-color: rgba(22, 24, 29, 0.28);
521 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent);
560 border-color: var(--border-focus);
561 box-shadow: var(--ring);
522562}
523563
524564.si-email-input::placeholder {
525 color: #c4c6cf;
565 color: var(--text-faint);
526566}
527567
528568.si-magic-btn {
530570 border-radius: 10px;
531571 font-size: 13.5px;
532572 font-weight: 600;
533 font-family: 'Inter', sans-serif;
573 font-family: var(--font-sans);
534574 letter-spacing: -0.008em;
535 border: 1px solid rgba(22, 24, 29, 0.14);
536 background: #ffffff;
537 color: #16181d;
575 border: 1px solid var(--border-strong);
576 background: var(--bg-elevated);
577 color: var(--text);
538578 cursor: pointer;
539579 white-space: nowrap;
540580 transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
541581}
542582
543583.si-magic-btn:hover {
544 border-color: rgba(22, 24, 29, 0.28);
584 border-color: color-mix(in srgb, var(--text) 30%, transparent);
545585}
546586
547587.si-magic-btn[aria-busy="true"] {
564604
565605.si-magic-hint {
566606 font-size: 12px;
567 color: #8a8d99;
607 color: var(--text-muted);
568608 margin: 10px 0 0;
569609}
570610
571611/* ── Auxiliary links ── */
572612
573613.si-aux-links {
574 border-top: 1px solid rgba(22, 24, 29, 0.07);
614 border-top: 1px solid var(--border);
575615 margin-top: 28px;
576616 padding-top: 20px;
577617 display: flex;
581621
582622.si-aux-link {
583623 font-size: 13px;
584 color: #6b7080;
624 color: var(--text-muted);
585625 text-decoration: none;
586626 transition: color 0.12s ease;
587627}
588628
589629.si-aux-link:hover {
590 color: #16181d;
630 color: var(--text);
591631}
592632
593633.si-aux-link-accent {
595635}
596636
597637.si-aux-link-accent:hover {
598 color: #3544b0;
638 color: var(--accent-hover);
599639 text-decoration: underline;
600640}
601641
603643
604644.si-footer-note {
605645 font-size: 11.5px;
606 color: #8a8d99;
646 color: var(--text-faint);
607647 margin: 0;
608 font-family: 'JetBrains Mono', monospace;
648 font-family: var(--font-mono);
609649 letter-spacing: 0.01em;
610650}
611651
612652/* ── Right panel ── */
653/* Deliberately dark in BOTH themes (a fixed proof panel, not a themed
654 surface), so its colors stay literal. Content is centered so the wider
655 column the capped left grid track hands it doesn't read as dead space. */
613656
614657.si-right {
615658 background: radial-gradient(130% 130% at 50% -10%, #1b2030 0%, #12151f 45%, #0b0d13 100%);
617660 display: flex;
618661 flex-direction: column;
619662 justify-content: center;
663 align-items: center;
620664 padding: 64px 56px;
621665 position: relative;
622666 overflow: hidden;
641685}
642686
643687.si-proof-eyebrow {
644 font-family: 'JetBrains Mono', monospace;
688 font-family: var(--font-mono);
645689 font-size: 11px;
646690 letter-spacing: 0.12em;
647691 text-transform: uppercase;
648 color: #a9b4ee;
692 /* was #a9b4ee — an indigo, the banned family. Evergreen-on-dark instead
693 (the dark theme's link tone; literal because this panel never flips). */
694 color: #7fc4b4;
649695 margin-bottom: 16px;
650696}
651697
652698
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts