feat(design): health page to Natural Light standard + humanized AI errors #5443
4 changed files+280−111
Modifiedsrc/__tests__/intelligence.test.ts+2−2View fileUnifiedSplit
@@ -243,9 +243,9 @@ describe("health dashboard route", () => {
243243 const res = await app.request("/dev/myapp/health");
244244 expect(res.status).toBe(200);
245245 const html = await res.text();
246 expect(html).toContain("Health Score");
246 expect(html).toContain("Health score");
247247 expect(html).toContain("Security");
248248 expect(html).toContain("Testing");
249 expect(html).toContain("Zero-Config CI");
249 expect(html).toContain("Zero-config CI");
250250 }, GIT_HEAVY_MS);
251251});
Modifiedsrc/lib/ai-client.ts+32−0View fileUnifiedSplit
@@ -220,6 +220,38 @@ export function modelForTask(task: AiTask): string {
220220 : standard;
221221}
222222
223/**
224 * Translate raw Anthropic API failures into sentences a user can act on.
225 * The raw form — "AI call failed: 400 {json blob with request_id}" — was
226 * shown verbatim on the spec-to-PR progress page; a paying user should
227 * never be handed another company's error envelope.
228 */
229export function humanizeAiError(raw: unknown): string {
230 const msg =
231 raw instanceof Error ? raw.message : typeof raw === "string" ? raw : String(raw);
232 const lower = msg.toLowerCase();
233 if (lower.includes("credit balance is too low")) {
234 return (
235 "The platform's AI balance is exhausted, so AI generation is " +
236 "temporarily unavailable. Nothing was lost. You can queue this as a " +
237 "repair for the internal agent from the repository's Health page, or " +
238 "try again once the balance is restored."
239 );
240 }
241 if (lower.includes("authentication") || lower.includes("invalid x-api-key")) {
242 return "The platform's AI credentials are misconfigured — the site admin has been the right person to fix this.";
243 }
244 if (lower.includes("rate_limit") || lower.includes("429")) {
245 return "AI is briefly rate-limited — wait a minute and try again.";
246 }
247 if (lower.includes("overloaded") || lower.includes("529")) {
248 return "The AI service is momentarily overloaded — try again shortly.";
249 }
250 // Unknown failure: keep it short and drop any JSON envelope.
251 const firstLine = msg.split("{")[0].trim().replace(/[:\s]+$/, "");
252 return `AI generation failed (${firstLine || "unexpected error"}). Try again shortly.`;
253}
254
223255/**
224256 * Extract text content from an Anthropic message response.
225257 */
Modifiedsrc/lib/spec-ai.ts+8−2View fileUnifiedSplit
@@ -18,7 +18,11 @@
1818 */
1919
2020import { config } from "./config";
21import { getAnthropic, __resetAnthropicClientForTests } from "./ai-client";
21import {
22 getAnthropic,
23 humanizeAiError,
24 __resetAnthropicClientForTests,
25} from "./ai-client";
2226
2327// ---------------------------------------------------------------------------
2428// Public types
@@ -330,7 +334,9 @@ export async function generateSpecEdits(
330334 }
331335 }
332336 } catch (err) {
333 return { ok: false, error: `AI call failed: ${errMessage(err)}` };
337 // Users were shown Anthropic's raw error envelope ("400 {json...}") on
338 // the spec progress page — translate to something actionable.
339 return { ok: false, error: humanizeAiError(err) };
334340 }
335341
336342 const parsed = parseAiJsonResponse(rawText);
Modifiedsrc/routes/health.tsx+238−107View fileUnifiedSplit
@@ -34,6 +34,158 @@ const health = new Hono<AuthEnv>();
3434
3535health.use("*", softAuth);
3636
37// ─── PAGE-SCOPED CSS ─────────────────────────────────────────
38// All classes prefixed .hlth- so nothing bleeds into neighbouring routes.
39// Natural Light idiom: hairline geometry, mono-as-feature, one evergreen
40// accent, restrained motion. This page is the reference implementation for
41// the "professional design" pass — copy its patterns, not its pixels.
42const hlthStyles = `
43 .hlth-wrap { display: flex; flex-direction: column; gap: var(--space-6); }
44
45 /* ─── Hero: grade + insights ─── */
46 .hlth-hero {
47 position: relative; display: flex; gap: var(--space-6); flex-wrap: wrap;
48 padding: var(--space-5) var(--space-6);
49 background: var(--bg-elevated); border: 1px solid var(--border);
50 border-radius: var(--r-lg); overflow: hidden;
51 }
52 .hlth-hero::before {
53 content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
54 background: linear-gradient(90deg, transparent, var(--accent) 30%, var(--accent-2) 70%, transparent);
55 opacity: .75; pointer-events: none;
56 }
57 .hlth-grade {
58 display: flex; flex-direction: column; align-items: center; justify-content: center;
59 min-width: 148px; padding: var(--space-4) var(--space-5);
60 border: 1px solid var(--border-strong); border-radius: var(--r-md);
61 background: var(--bg-secondary);
62 }
63 .hlth-grade-letter {
64 font-family: var(--font-display); font-size: 44px; font-weight: 800; line-height: 1;
65 }
66 .hlth-grade-score {
67 font-family: var(--font-mono); font-size: 20px; font-weight: 600;
68 color: var(--text); margin-top: 6px;
69 }
70 .hlth-grade-label {
71 font-size: var(--t-xs); color: var(--text-muted); margin-top: 4px;
72 text-transform: uppercase; letter-spacing: .08em;
73 }
74 .hlth-insights { flex: 1; min-width: 280px; }
75 .hlth-insights h3 {
76 font-size: var(--t-sm); text-transform: uppercase; letter-spacing: .08em;
77 color: var(--text-muted); margin-bottom: var(--space-3); font-weight: 600;
78 }
79 .hlth-insight {
80 display: flex; gap: 10px; align-items: baseline; padding: 7px 0;
81 font-size: var(--t-base); border-bottom: 1px solid var(--border-subtle);
82 }
83 .hlth-insight:last-child { border-bottom: none; }
84 .hlth-insight-dot { color: var(--accent); flex-shrink: 0; font-weight: 700; }
85
86 /* ─── Improvements panel ─── */
87 .hlth-improve {
88 border: 1px solid var(--border); border-radius: var(--r-lg);
89 background: var(--bg-elevated); overflow: hidden;
90 }
91 .hlth-improve-head {
92 display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
93 padding: 12px var(--space-5); background: var(--bg-secondary);
94 border-bottom: 1px solid var(--border);
95 }
96 .hlth-improve-title { font-size: var(--t-md); font-weight: 650; }
97 .hlth-improve-chip {
98 font-family: var(--font-mono); font-size: var(--t-sm); font-weight: 600;
99 color: var(--accent); border: 1px solid var(--border-focus);
100 border-radius: var(--r-full); padding: 2px 12px; white-space: nowrap;
101 }
102 .hlth-improve-sub {
103 padding: 10px var(--space-5); font-size: var(--t-sm); color: var(--text-muted);
104 border-bottom: 1px solid var(--border-subtle);
105 }
106 .hlth-improve-error {
107 margin: 10px var(--space-5) 0; padding: 8px 14px;
108 border: 1px solid var(--red); border-radius: var(--r);
109 color: var(--red); font-size: var(--t-sm);
110 }
111 .hlth-row {
112 display: flex; gap: var(--space-4); align-items: center;
113 padding: 12px var(--space-5); border-bottom: 1px solid var(--border-subtle);
114 }
115 .hlth-row:last-child { border-bottom: none; }
116 .hlth-row:hover { background: var(--bg-hover); }
117 .hlth-gain {
118 font-family: var(--font-mono); font-size: var(--t-base); font-weight: 700;
119 color: var(--accent); min-width: 56px; text-align: right; flex-shrink: 0;
120 }
121 .hlth-row-body { flex: 1; min-width: 0; }
122 .hlth-row-action { font-size: var(--t-base); font-weight: 500; }
123 .hlth-row-cat {
124 display: inline-block; margin-left: 8px; padding: 1px 8px;
125 font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
126 color: var(--text-muted); border: 1px solid var(--border);
127 border-radius: var(--r-full); vertical-align: 1px;
128 }
129 .hlth-row-detail { font-size: var(--t-sm); color: var(--text-muted); margin-top: 2px; }
130 .hlth-fix-btn {
131 flex-shrink: 0; font-size: var(--t-sm); font-weight: 550;
132 padding: 5px 14px; border-radius: var(--r);
133 border: 1px solid var(--accent); color: var(--accent); background: transparent;
134 cursor: pointer; transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
135 }
136 .hlth-fix-btn:hover { background: var(--accent); color: var(--bg-elevated); }
137 .hlth-improve-foot {
138 padding: 10px var(--space-5); font-size: var(--t-xs); color: var(--text-faint);
139 border-top: 1px solid var(--border-subtle); background: var(--bg-secondary);
140 }
141
142 /* ─── Score cards ─── */
143 .hlth-grid {
144 display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
145 gap: var(--space-4);
146 }
147 .hlth-card {
148 padding: var(--space-4) var(--space-5);
149 background: var(--bg-elevated); border: 1px solid var(--border);
150 border-radius: var(--r-md);
151 }
152 .hlth-card-head {
153 display: flex; justify-content: space-between; align-items: baseline;
154 margin-bottom: 10px;
155 }
156 .hlth-card-title {
157 font-size: var(--t-sm); font-weight: 600; text-transform: uppercase;
158 letter-spacing: .07em; color: var(--text-muted);
159 }
160 .hlth-card-score { font-family: var(--font-mono); font-size: var(--t-lg); font-weight: 700; }
161 .hlth-bar {
162 height: 3px; background: var(--bg-tertiary); border-radius: 2px;
163 overflow: hidden; margin-bottom: 8px;
164 }
165 .hlth-bar-fill { height: 100%; border-radius: 2px; transition: width .3s var(--ease); }
166 .hlth-card-attr { font-size: 11px; color: var(--text-faint); margin-bottom: 6px; }
167 .hlth-card-detail { font-size: var(--t-sm); color: var(--text-muted); margin-top: 2px; }
168
169 /* ─── Security groups ─── */
170 .hlth-sec {
171 border: 1px solid var(--border); border-radius: var(--r-lg);
172 background: var(--bg-elevated); overflow: hidden;
173 }
174 .hlth-sec-head {
175 display: flex; align-items: baseline; gap: 10px;
176 padding: 12px var(--space-5); background: var(--bg-secondary);
177 border-bottom: 1px solid var(--border);
178 }
179 .hlth-sec-title { font-size: var(--t-md); font-weight: 650; }
180 .hlth-sec-meta { font-size: var(--t-sm); color: var(--text-muted); }
181 .hlth-sec details { border-bottom: 1px solid var(--border-subtle); }
182 .hlth-sec details:last-child { border-bottom: none; }
183 .hlth-sec summary { padding: 12px var(--space-5); cursor: pointer; list-style: none; }
184 .hlth-sec summary::-webkit-details-marker { display: none; }
185 .hlth-sec summary:hover { background: var(--bg-hover); }
186 .hlth-sec-files { padding: 0 var(--space-5) 12px calc(var(--space-5) + 28px); }
187`;
188
37189health.get("/:owner/:repo/health", async (c) => {
38190 const { owner, repo } = c.req.param();
39191 const user = c.get("user");
@@ -88,26 +240,21 @@ health.get("/:owner/:repo/health", async (c) => {
88240 <RepoHeader owner={owner} repo={repo} />
89241 <HealthNav owner={owner} repo={repo} active="health" />
90242
91 <div style="display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 32px">
92 <div
93 style={`text-align: center; padding: 24px 40px; background: var(--bg-secondary); border: 2px solid ${gradeColor}; border-radius: var(--radius);`}
94 >
95 <div style={`font-size: 48px; font-weight: 800; color: ${gradeColor}`}>
243 <div class="hlth-wrap">
244 <div class="hlth-hero">
245 <div class="hlth-grade" style={`border-color: ${gradeColor}`}>
246 <div class="hlth-grade-letter" style={`color: ${gradeColor}`}>
96247 {report.grade}
97248 </div>
98 <div style="font-size: 32px; font-weight: 600; color: var(--text)">
99 {report.score}/100
100 </div>
101 <div style="font-size: 13px; color: var(--text-muted); margin-top: 4px">
102 Health Score
103 </div>
249 <div class="hlth-grade-score">{report.score}/100</div>
250 <div class="hlth-grade-label">Health score</div>
104251 </div>
105252
106 <div style="flex: 1; min-width: 300px">
107 <h3 style="margin-bottom: 12px">Insights</h3>
253 <div class="hlth-insights">
254 <h3>Insights</h3>
108255 {report.insights.map((insight) => (
109 <div style="padding: 8px 0; font-size: 14px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: start">
110 <span style="color: var(--text-link); flex-shrink: 0">*</span>
256 <div class="hlth-insight">
257 <span class="hlth-insight-dot" aria-hidden="true">›</span>
111258 <span>{insight}</span>
112259 </div>
113260 ))}
@@ -115,50 +262,44 @@ health.get("/:owner/:repo/health", async (c) => {
115262 </div>
116263
117264 {improvements.length > 0 && (
118 <div style="margin-bottom: 32px">
119 <h3 style="margin-bottom: 4px">How to improve this score</h3>
120 <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
121 Completing everything below would take this repo from{" "}
122 <strong>{report.score}</strong> to about{" "}
123 <strong>{potential}</strong>/100. Gains are computed from the
124 same formula that produced the score.
265 <div class="hlth-improve">
266 <div class="hlth-improve-head">
267 <span class="hlth-improve-title">How to improve this score</span>
268 <span class="hlth-improve-chip">
269 {report.score} → {potential} possible
270 </span>
271 </div>
272 <div class="hlth-improve-sub">
273 Gains are computed from the same formula that produced the score —
274 completing everything below is worth {potential - report.score}{" "}
275 points.
125276 </div>
126277 {repairError && (
127 <div style="margin-bottom: 12px; padding: 10px 14px; border: 1px solid var(--red); border-radius: var(--radius); color: var(--red); font-size: 13px">
128 Repair failed: {repairError}
129 </div>
278 <div class="hlth-improve-error">Repair failed: {repairError}</div>
130279 )}
131 <div class="issue-list">
280 <div>
132281 {improvements.map((imp, idx) => (
133 <div class="issue-item" style="display: flex; gap: 12px; align-items: baseline">
134 <span style="font-size: 14px; font-weight: 700; color: var(--green); flex-shrink: 0; min-width: 52px; text-align: right; font-family: var(--font-mono)">
135 +{imp.overallGain}
136 </span>
137 <div style="min-width: 0; flex: 1">
138 <div style="font-size: 14px; font-weight: 500">
282 <div class="hlth-row">
283 <span class="hlth-gain">+{imp.overallGain}</span>
284 <div class="hlth-row-body">
285 <div class="hlth-row-action">
139286 {idx + 1}. {imp.action}
140 <span class="badge" style="margin-left: 8px; font-size: 10px; text-transform: capitalize">
141 {imp.category}
142 </span>
143 </div>
144 <div style="font-size: 12px; color: var(--text-muted)">
145 {imp.detail}
287 <span class="hlth-row-cat">{imp.category}</span>
146288 </div>
289 <div class="hlth-row-detail">{imp.detail}</div>
147290 </div>
148291 {canRepair && (
149292 <form
150293 method="post"
151294 action={`/${owner}/${repo}/health/repair`}
152 style="flex-shrink: 0"
153295 >
154296 <input type="hidden" name="action" value={imp.action} />
155297 <input type="hidden" name="detail" value={imp.detail} />
156298 <input type="hidden" name="category" value={imp.category} />
157299 <button
158300 type="submit"
159 class="btn"
160 style="font-size: 12px; padding: 4px 10px"
161 title="Have the AI implement this fix and open a draft PR for your review"
301 class="hlth-fix-btn"
302 title="Have the AI implement this fix and open a pull request for your review"
162303 >
163304 Fix this for me
164305 </button>
@@ -168,16 +309,16 @@ health.get("/:owner/:repo/health", async (c) => {
168309 ))}
169310 </div>
170311 {canRepair && (
171 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
172 "Fix this for me" has the AI implement the change on a branch and
173 open a pull request for your review — nothing lands on{" "}
312 <div class="hlth-improve-foot">
313 "Fix this for me" queues the change for the repair agent — it
314 lands as a pull request for your review; nothing reaches{" "}
174315 <code>{ref}</code> without you merging it.
175316 </div>
176317 )}
177318 </div>
178319 )}
179320
180 <div class="card-grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))">
321 <div class="hlth-grid">
181322 <ScoreCard
182323 title="Security"
183324 score={report.breakdown.security.score}
@@ -252,23 +393,23 @@ health.get("/:owner/:repo/health", async (c) => {
252393 </div>
253394
254395 {securityGroups.length > 0 && (
255 <div style="margin-top: 32px">
256 <h3 style="margin-bottom: 12px">
257 Security Issues
258 <span style="font-size: 13px; color: var(--text-muted); font-weight: 400; margin-left: 8px">
396 <div class="hlth-sec">
397 <div class="hlth-sec-head">
398 <span class="hlth-sec-title">Security findings</span>
399 <span class="hlth-sec-meta">
259400 {report.breakdown.security.issues.length} finding
260401 {report.breakdown.security.issues.length !== 1 ? "s" : ""} ·{" "}
261402 {securityGroups.length} distinct rule
262403 {securityGroups.length !== 1 ? "s" : ""}
263404 </span>
264 </h3>
265 <div class="issue-list">
266 {securityGroups.map((g) => (
267 <details class="issue-item" style="display: block">
268 <summary style="display: flex; gap: 8px; align-items: center; cursor: pointer; list-style: none">
405 </div>
406 {securityGroups.map((g) => (
407 <details>
408 <summary>
409 <div style="display: flex; gap: 10px; align-items: center">
269410 <SeverityBadge severity={g.severity} />
270411 <div style="flex: 1; min-width: 0">
271 <div style="font-size: 14px; font-weight: 500">
412 <div class="hlth-row-action">
272413 {g.message}
273414 <span style="color: var(--text-muted); font-weight: 400">
274415 {" "}
@@ -276,7 +417,7 @@ health.get("/:owner/:repo/health", async (c) => {
276417 {g.files.length} file{g.files.length !== 1 ? "s" : ""}
277418 </span>
278419 </div>
279 <div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
420 <div class="hlth-row-detail" style="font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
280421 {g.rule} ·{" "}
281422 {g.files
282423 .slice(0, 3)
@@ -287,51 +428,46 @@ health.get("/:owner/:repo/health", async (c) => {
287428 : ""}
288429 </div>
289430 </div>
290 <span style="font-size: 12px; color: var(--text-muted); flex-shrink: 0">
291 details
292 </span>
293 </summary>
294 <div style="margin-top: 10px; padding-left: 28px">
295 {g.files.map((f) => (
296 <div style="font-size: 12px; font-family: var(--font-mono); padding: 3px 0; color: var(--text-muted)">
297 <span style="color: var(--text)">{f.file}</span>
298 {" — "}
299 {f.count === 1 ? "line " : "lines "}
300 {f.lines.join(", ")}
301 </div>
302 ))}
431 <span class="hlth-sec-meta" style="flex-shrink: 0">details</span>
303432 </div>
304 </details>
305 ))}
306 </div>
433 </summary>
434 <div class="hlth-sec-files">
435 {g.files.map((f) => (
436 <div class="hlth-row-detail" style="font-family: var(--font-mono); padding: 3px 0">
437 <span style="color: var(--text)">{f.file}</span>
438 {" — "}
439 {f.count === 1 ? "line " : "lines "}
440 {f.lines.join(", ")}
441 </div>
442 ))}
443 </div>
444 </details>
445 ))}
307446 </div>
308447 )}
309448
310449 {ciConfig.commands.length > 0 && (
311 <div style="margin-top: 32px">
312 <h3 style="margin-bottom: 12px">
313 Zero-Config CI
314 <span style="font-size: 13px; color: var(--text-muted); font-weight: 400; margin-left: 8px">
315 Auto-detected
450 <div class="hlth-sec">
451 <div class="hlth-sec-head">
452 <span class="hlth-sec-title">Zero-config CI</span>
453 <span class="hlth-sec-meta">
454 Auto-detected · {ciConfig.detected.join(" · ")}
316455 </span>
317 </h3>
318 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px">
319 <div style="margin-bottom: 12px; font-size: 13px; color: var(--text-muted)">
320 {ciConfig.detected.join(" | ")}
321 </div>
322 {ciConfig.commands.map((cmd) => (
323 <div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: center">
324 <span style="font-size: 14px; font-weight: 500">
325 {cmd.name}
326 </span>
327 <code style="font-size: 12px; background: var(--bg-tertiary); padding: 4px 8px; border-radius: 3px">
328 {cmd.command}
329 </code>
330 </div>
331 ))}
332456 </div>
457 {ciConfig.commands.map((cmd) => (
458 <div class="hlth-row">
459 <div class="hlth-row-body">
460 <span class="hlth-row-action">{cmd.name}</span>
461 </div>
462 <code style="font-size: var(--t-sm); font-family: var(--font-mono); background: var(--bg-tertiary); padding: 4px 10px; border-radius: var(--r-sm)">
463 {cmd.command}
464 </code>
465 </div>
466 ))}
333467 </div>
334468 )}
469 </div>
470 <style dangerouslySetInnerHTML={{ __html: hlthStyles }} />
335471 </Layout>
336472 );
337473});
@@ -562,31 +698,26 @@ const ScoreCard = ({
562698 ? null
563699 : Math.round((score * weightPct) / 100);
564700 return (
565 <div class="card">
566 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
567 <h3 style="font-size: 15px">{title}</h3>
568 <span
569 style={`font-size: 18px; font-weight: 700; color: ${color}`}
570 >
701 <div class="hlth-card">
702 <div class="hlth-card-head">
703 <span class="hlth-card-title">{title}</span>
704 <span class="hlth-card-score" style={`color: ${color}`}>
571705 {notScored ? "—" : score}
572706 </span>
573707 </div>
574 <div
575 style="height: 4px; background: var(--bg-tertiary); border-radius: 2px; margin-bottom: 8px; overflow: hidden"
576 >
708 <div class="hlth-bar">
577709 <div
578 style={`height: 100%; width: ${notScored ? 0 : score}%; background: ${color}; border-radius: 2px; transition: width 0.3s;`}
710 class="hlth-bar-fill"
711 style={`width: ${notScored ? 0 : score}%; background: ${color};`}
579712 />
580713 </div>
581 <div style="font-size: 11px; color: var(--text-faint); margin-bottom: 4px">
714 <div class="hlth-card-attr">
582715 {notScored
583716 ? "Not counted in the overall score"
584717 : `${contribution} of ${weightPct} possible overall points (weight ${weightPct}%)`}
585718 </div>
586719 {details.map((d) => (
587 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
588 {d}
589 </div>
720 <div class="hlth-card-detail">{d}</div>
590721 ))}
591722 </div>
592723 );
593724
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts