Commite73e288unknown_key
polish(ai): 2026 hero + result panels for ai-explain + ai-changelog + ai-tests
3 files changed+2013−301e73e2881b0e69c8eb1a71ff445a5a1c68f12acde
3 changed files+2013−301
Modifiedsrc/routes/ai-changelog.tsx+751−121View fileUnifiedSplit
@@ -10,6 +10,11 @@
1010 *
1111 * Public repos are readable without auth (softAuth) — matching the
1212 * behaviour of `src/routes/compare.tsx`.
13 *
14 * 2026 polish — every form action, POST target, ?format=markdown branch,
15 * and the `generateChangelog(...)` AI prompt construction are preserved
16 * verbatim. Visual treatment scoped under `.ai-changelog-*` so this
17 * surface can't share CSS with ai-explain or ai-tests.
1318 */
1419
1520import { Hono } from "hono";
@@ -32,6 +37,515 @@ const aiChangelog = new Hono<AuthEnv>();
3237
3338aiChangelog.use("*", softAuth);
3439
40/* ─────────────────────────────────────────────────────────────────────────
41 * Scoped CSS — every class prefixed `.ai-changelog-` so this surface can't
42 * bleed into ai-explain or ai-tests. Mirrors the gradient-hairline hero,
43 * focus-rings on inputs (uses :root --border-focus token), white result
44 * panel + copy-to-clipboard pattern from admin-integrations.tsx.
45 * ───────────────────────────────────────────────────────────────────── */
46const styles = `
47 .ai-changelog-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
48
49 .ai-changelog-hero {
50 position: relative;
51 margin-bottom: var(--space-5);
52 padding: var(--space-5) var(--space-6);
53 background: var(--bg-elevated);
54 border: 1px solid var(--border);
55 border-radius: 16px;
56 overflow: hidden;
57 }
58 .ai-changelog-hero::before {
59 content: '';
60 position: absolute;
61 top: 0; left: 0; right: 0;
62 height: 2px;
63 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
64 opacity: 0.7;
65 pointer-events: none;
66 }
67 .ai-changelog-hero-orb {
68 position: absolute;
69 inset: -20% -10% auto auto;
70 width: 420px; height: 420px;
71 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
72 filter: blur(80px);
73 opacity: 0.75;
74 pointer-events: none;
75 z-index: 0;
76 }
77 .ai-changelog-hero-inner { position: relative; z-index: 1; max-width: 720px; }
78 .ai-changelog-eyebrow {
79 font-size: 12px;
80 color: var(--text-muted);
81 margin-bottom: var(--space-2);
82 letter-spacing: 0.02em;
83 display: inline-flex;
84 align-items: center;
85 gap: 8px;
86 }
87 .ai-changelog-eyebrow .pill {
88 display: inline-flex;
89 align-items: center;
90 justify-content: center;
91 width: 18px; height: 18px;
92 border-radius: 6px;
93 background: rgba(140,109,255,0.14);
94 color: #b69dff;
95 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
96 }
97 .ai-changelog-title {
98 font-size: clamp(28px, 4vw, 42px);
99 font-family: var(--font-display);
100 font-weight: 800;
101 letter-spacing: -0.028em;
102 line-height: 1.05;
103 margin: 0 0 var(--space-2);
104 color: var(--text-strong);
105 }
106 .ai-changelog-title-grad {
107 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
108 -webkit-background-clip: text;
109 background-clip: text;
110 -webkit-text-fill-color: transparent;
111 color: transparent;
112 }
113 .ai-changelog-sub {
114 font-size: 15px;
115 color: var(--text-muted);
116 margin: 0;
117 line-height: 1.55;
118 max-width: 640px;
119 }
120
121 /* Banners (error / notice). Keep the legacy .auth-error class on the
122 error banner because the test asserts against it; just upgrade the
123 visual via the new wrapping class. */
124 .ai-changelog-banner {
125 margin-bottom: var(--space-4);
126 padding: 10px 14px;
127 border-radius: 10px;
128 font-size: 13.5px;
129 border: 1px solid var(--border);
130 background: rgba(255,255,255,0.025);
131 color: var(--text);
132 }
133 .ai-changelog-banner.is-error {
134 border-color: rgba(248,113,113,0.40);
135 background: rgba(248,113,113,0.08);
136 color: #fecaca;
137 }
138 .ai-changelog-banner.is-notice {
139 border-color: rgba(140,109,255,0.30);
140 background: rgba(140,109,255,0.06);
141 color: var(--text);
142 }
143
144 /* Form card */
145 .ai-changelog-form-card {
146 margin-bottom: var(--space-5);
147 background: var(--bg-elevated);
148 border: 1px solid var(--border);
149 border-radius: 14px;
150 padding: var(--space-4) var(--space-5);
151 }
152 .ai-changelog-form-row {
153 display: flex;
154 gap: 12px;
155 align-items: end;
156 flex-wrap: wrap;
157 }
158 .ai-changelog-field {
159 display: flex;
160 flex-direction: column;
161 gap: 6px;
162 min-width: 180px;
163 flex: 1 1 200px;
164 }
165 .ai-changelog-field-label {
166 font-size: 11px;
167 font-weight: 700;
168 text-transform: uppercase;
169 letter-spacing: 0.12em;
170 color: var(--text-muted);
171 }
172 .ai-changelog-input {
173 width: 100%;
174 padding: 9px 12px;
175 font-size: 13.5px;
176 color: var(--text);
177 background: var(--bg);
178 border: 1px solid var(--border-strong);
179 border-radius: 8px;
180 outline: none;
181 font-family: var(--font-mono);
182 transition: border-color 120ms ease, box-shadow 120ms ease;
183 box-sizing: border-box;
184 }
185 .ai-changelog-input:focus {
186 border-color: var(--border-focus);
187 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
188 }
189 .ai-changelog-submit {
190 display: inline-flex;
191 align-items: center;
192 gap: 7px;
193 padding: 9px 16px;
194 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
195 color: #ffffff;
196 border: 1px solid transparent;
197 border-radius: 10px;
198 font-size: 13.5px;
199 font-weight: 600;
200 cursor: pointer;
201 box-shadow: 0 6px 16px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
202 font-family: inherit;
203 transition: transform 120ms ease, box-shadow 120ms ease;
204 line-height: 1;
205 }
206 .ai-changelog-submit:hover {
207 transform: translateY(-1px);
208 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
209 }
210 .ai-changelog-submit svg { display: block; }
211
212 .ai-changelog-ghost {
213 display: inline-flex;
214 align-items: center;
215 gap: 6px;
216 padding: 9px 14px;
217 background: transparent;
218 color: var(--text);
219 border: 1px solid var(--border-strong);
220 border-radius: 10px;
221 font-size: 13px;
222 font-weight: 600;
223 text-decoration: none;
224 cursor: pointer;
225 font-family: inherit;
226 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
227 line-height: 1;
228 }
229 .ai-changelog-ghost:hover {
230 background: rgba(140,109,255,0.06);
231 border-color: rgba(140,109,255,0.45);
232 color: var(--text-strong);
233 text-decoration: none;
234 }
235
236 .ai-changelog-knownrefs {
237 margin-top: var(--space-3);
238 font-size: 11.5px;
239 color: var(--text-muted);
240 }
241 .ai-changelog-knownrefs code {
242 font-family: var(--font-mono);
243 background: var(--bg-tertiary);
244 color: var(--text);
245 padding: 1px 5px;
246 border-radius: 4px;
247 font-size: 11px;
248 margin: 0 2px;
249 }
250
251 /* Meta line under the title once a range is loaded. */
252 .ai-changelog-rangeline {
253 display: flex;
254 align-items: center;
255 gap: 10px;
256 flex-wrap: wrap;
257 color: var(--text-muted);
258 font-size: 12.5px;
259 margin: var(--space-2) 0 var(--space-4);
260 }
261 .ai-changelog-rangeline code {
262 font-family: var(--font-mono);
263 background: var(--bg-tertiary);
264 color: var(--text);
265 padding: 1px 6px;
266 border-radius: 4px;
267 font-size: 12px;
268 }
269 .ai-changelog-rangeline .arrow { opacity: 0.55; }
270 .ai-changelog-rangeline .count {
271 display: inline-flex;
272 align-items: center;
273 gap: 5px;
274 padding: 2px 8px;
275 border-radius: 9999px;
276 background: rgba(140,109,255,0.10);
277 color: #c4b5fd;
278 font-size: 11px;
279 font-weight: 600;
280 letter-spacing: 0.04em;
281 text-transform: uppercase;
282 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
283 }
284
285 /* Result panels — split: rendered MD (left) + copyable raw (right) */
286 .ai-changelog-results {
287 display: grid;
288 grid-template-columns: 1fr 1fr;
289 gap: 20px;
290 align-items: start;
291 }
292 @media (max-width: 800px) {
293 .ai-changelog-results { grid-template-columns: 1fr; }
294 }
295
296 .ai-changelog-panel {
297 position: relative;
298 background: #ffffff;
299 color: #0a0a0a;
300 border: 1px solid #e5e7eb;
301 border-radius: 14px;
302 overflow: hidden;
303 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
304 }
305 .ai-changelog-panel-head {
306 display: flex;
307 align-items: center;
308 justify-content: space-between;
309 gap: 12px;
310 padding: 12px 16px;
311 background: #f9fafb;
312 border-bottom: 1px solid #e5e7eb;
313 flex-wrap: wrap;
314 }
315 .ai-changelog-panel-title {
316 display: flex;
317 align-items: center;
318 gap: 10px;
319 font-family: var(--font-display, system-ui, sans-serif);
320 font-size: 13.5px;
321 font-weight: 700;
322 color: #111827;
323 letter-spacing: -0.005em;
324 margin: 0;
325 }
326 .ai-changelog-panel-dot {
327 width: 8px; height: 8px;
328 border-radius: 9999px;
329 background: linear-gradient(135deg, #8c6dff, #36c5d6);
330 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
331 }
332 .ai-changelog-copy {
333 display: inline-flex;
334 align-items: center;
335 gap: 6px;
336 padding: 6px 12px;
337 font-size: 12.5px;
338 font-weight: 600;
339 color: #111827;
340 background: #ffffff;
341 border: 1px solid #d1d5db;
342 border-radius: 8px;
343 cursor: pointer;
344 font-family: inherit;
345 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
346 }
347 .ai-changelog-copy:hover { background: #f3f4f6; border-color: #9ca3af; }
348 .ai-changelog-copy.is-copied {
349 background: #ecfdf5;
350 border-color: #6ee7b7;
351 color: #047857;
352 }
353 .ai-changelog-copy svg { display: block; }
354 .ai-changelog-panel-body {
355 padding: 18px 22px;
356 background: #ffffff;
357 color: #0a0a0a;
358 }
359 .ai-changelog-panel-body .markdown-body {
360 color: #0a0a0a;
361 background: #ffffff;
362 font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
363 font-size: 14.5px;
364 line-height: 1.65;
365 }
366 .ai-changelog-panel-body .markdown-body h1,
367 .ai-changelog-panel-body .markdown-body h2,
368 .ai-changelog-panel-body .markdown-body h3 {
369 color: #0a0a0a;
370 border-bottom-color: #e5e7eb;
371 }
372 .ai-changelog-panel-body .markdown-body code {
373 background: #eef2ff;
374 color: #4338ca;
375 padding: 1px 5px;
376 border-radius: 4px;
377 font-family: var(--font-mono);
378 font-size: 12.5px;
379 }
380 .ai-changelog-panel-body .markdown-body a { color: #4338ca; }
381
382 .ai-changelog-raw {
383 width: 100%;
384 box-sizing: border-box;
385 min-height: 360px;
386 padding: 14px 16px;
387 font-family: var(--font-mono);
388 font-size: 12.5px;
389 line-height: 1.6;
390 color: #0a0a0a;
391 background: #ffffff;
392 border: 0;
393 outline: 0;
394 resize: vertical;
395 white-space: pre;
396 overflow: auto;
397 }
398
399 /* Empty-state — dashed orb card with "try" prompts. */
400 .ai-changelog-empty {
401 position: relative;
402 margin: var(--space-4) 0;
403 padding: var(--space-5);
404 border: 1px dashed var(--border-strong, var(--border));
405 border-radius: 14px;
406 background: var(--bg-elevated);
407 text-align: center;
408 overflow: hidden;
409 }
410 .ai-changelog-empty-orb {
411 position: absolute;
412 inset: -50% 30% auto 30%;
413 width: 320px; height: 320px;
414 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.06) 45%, transparent 70%);
415 filter: blur(70px);
416 pointer-events: none;
417 z-index: 0;
418 }
419 .ai-changelog-empty > * { position: relative; z-index: 1; }
420 .ai-changelog-empty h3 {
421 margin: 0 0 6px;
422 font-family: var(--font-display);
423 font-size: 16px;
424 color: var(--text-strong);
425 }
426 .ai-changelog-empty p {
427 margin: 0 0 6px;
428 color: var(--text-muted);
429 font-size: 13px;
430 }
431 .ai-changelog-empty .ai-changelog-suggests {
432 display: inline-flex;
433 flex-direction: column;
434 gap: 6px;
435 margin-top: 10px;
436 font-size: 12px;
437 color: var(--text-muted);
438 text-align: left;
439 }
440 .ai-changelog-empty code {
441 font-family: var(--font-mono);
442 background: var(--bg-tertiary);
443 color: var(--text);
444 padding: 2px 6px;
445 border-radius: 4px;
446 font-size: 11.5px;
447 }
448
449 /* Skeleton (kept for future async modes — the route currently runs
450 synchronously, so the placeholder only shows when explicitly wired). */
451 @keyframes ai-changelog-shimmer {
452 0% { background-position: -300px 0; }
453 100% { background-position: 300px 0; }
454 }
455 .ai-changelog-skeleton {
456 height: 12px;
457 border-radius: 4px;
458 background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(140,109,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
459 background-size: 600px 100%;
460 animation: ai-changelog-shimmer 1.4s linear infinite;
461 margin-bottom: 8px;
462 }
463
464 /* Powered by Claude */
465 .ai-changelog-poweredby {
466 margin-top: var(--space-5);
467 text-align: center;
468 }
469 .ai-changelog-poweredby-pill {
470 display: inline-flex;
471 align-items: center;
472 gap: 6px;
473 padding: 4px 10px;
474 border-radius: 9999px;
475 background: rgba(140,109,255,0.08);
476 border: 1px solid rgba(140,109,255,0.22);
477 color: var(--text-muted);
478 font-size: 11px;
479 letter-spacing: 0.04em;
480 text-transform: uppercase;
481 font-weight: 600;
482 }
483 .ai-changelog-poweredby-pill .dot {
484 width: 6px; height: 6px;
485 border-radius: 9999px;
486 background: linear-gradient(135deg, #8c6dff, #36c5d6);
487 }
488
489 :root[data-theme='light'] .ai-changelog-panel {
490 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
491 }
492`;
493
494const COPY_SCRIPT = `
495 (function(){
496 var btn = document.querySelector('[data-ai-changelog-copy]');
497 var src = document.querySelector('[data-ai-changelog-raw]');
498 var label = document.querySelector('[data-ai-changelog-copy-label]');
499 if (!btn || !src || !label) return;
500 btn.addEventListener('click', function(){
501 var text = (src.value !== undefined) ? src.value : (src.innerText || src.textContent || '');
502 var done = function(){
503 btn.classList.add('is-copied');
504 label.textContent = 'Copied';
505 setTimeout(function(){
506 btn.classList.remove('is-copied');
507 label.textContent = 'Copy';
508 }, 1800);
509 };
510 if (navigator.clipboard && navigator.clipboard.writeText) {
511 navigator.clipboard.writeText(text).then(done).catch(function(){
512 try { src.focus && src.focus(); src.select && src.select(); document.execCommand('copy'); done(); } catch(e){}
513 });
514 } else {
515 try { src.focus && src.focus(); src.select && src.select(); document.execCommand('copy'); done(); } catch(e){}
516 }
517 });
518 })();
519`;
520
521function ChangelogHero() {
522 return (
523 <section class="ai-changelog-hero">
524 <div class="ai-changelog-hero-orb" aria-hidden="true" />
525 <div class="ai-changelog-hero-inner">
526 <div class="ai-changelog-eyebrow">
527 <span class="pill" aria-hidden="true">
528 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
529 <path d="M12 8v4l3 3" />
530 <circle cx="12" cy="12" r="9" />
531 </svg>
532 </span>
533 AI · gluecron · changelog
534 </div>
535 <h1 class="ai-changelog-title">
536 <span class="ai-changelog-title-grad">Track.</span>{" "}
537 <span style="color:var(--text-strong)">AI Changelog</span>
538 </h1>
539 <p class="ai-changelog-sub">
540 Generate release notes for any commit range. Pick a base (from) and
541 a head (to) — Claude will group commits into Features / Fixes /
542 Perf / Refactors / Docs / Other.
543 </p>
544 </div>
545 </section>
546 );
547}
548
35549interface RangeCommit {
36550 sha: string;
37551 message: string;
@@ -102,67 +616,105 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
102616 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
103617 <RepoHeader owner={owner} repo={repo} />
104618 <IssueNav owner={owner} repo={repo} active="code" />
105 <h2 style="margin-bottom: 8px">AI Changelog</h2>
106 <p style="color: var(--text-muted); margin-bottom: 20px; font-size: 14px">
107 Generate release notes for any commit range. Pick a base (from) and
108 a head (to) — Claude will group commits into Features / Fixes /
109 Perf / Refactors / Docs / Other.
110 </p>
111 {opts.error && (
112 <div class="auth-error" style="margin-bottom: 16px">
113 {opts.error}
114 </div>
115 )}
116 {opts.notice && (
117 <div
118 class="empty-state"
119 style="margin-bottom: 16px; padding: 12px; text-align: left"
120 >
121 {opts.notice}
619 <div class="ai-changelog-wrap">
620 <ChangelogHero />
621
622 {opts.error && (
623 <div class="ai-changelog-banner is-error auth-error">
624 {opts.error}
625 </div>
626 )}
627 {opts.notice && (
628 <div class="ai-changelog-banner is-notice">{opts.notice}</div>
629 )}
630
631 <div class="ai-changelog-form-card">
632 <form
633 method="get"
634 action={`/${owner}/${repo}/ai/changelog`}
635 class="ai-changelog-form-row"
636 >
637 <div class="ai-changelog-field">
638 <label class="ai-changelog-field-label" for="ai-changelog-from">
639 From
640 </label>
641 <input
642 id="ai-changelog-from"
643 type="text"
644 name="from"
645 list="ai-changelog-refs"
646 value={from}
647 placeholder="v1.0.0"
648 aria-label="From ref"
649 class="ai-changelog-input"
650 />
651 </div>
652 <div class="ai-changelog-field">
653 <label class="ai-changelog-field-label" for="ai-changelog-to">
654 To
655 </label>
656 <input
657 id="ai-changelog-to"
658 type="text"
659 name="to"
660 list="ai-changelog-refs"
661 value={to}
662 placeholder="main"
663 aria-label="To ref"
664 class="ai-changelog-input"
665 />
666 </div>
667 <datalist id="ai-changelog-refs">
668 {refChoices.map((r) => (
669 <option value={r}></option>
670 ))}
671 </datalist>
672 <button type="submit" class="ai-changelog-submit">
673 Generate
674 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
675 <line x1="5" y1="12" x2="19" y2="12" />
676 <polyline points="12 5 19 12 12 19" />
677 </svg>
678 </button>
679 </form>
680 {refChoices.length > 0 && (
681 <div class="ai-changelog-knownrefs">
682 Known refs:{" "}
683 {refChoices.slice(0, 20).map((r) => (
684 <code>{r}</code>
685 ))}
686 {refChoices.length > 20 ? " …" : ""}
687 </div>
688 )}
122689 </div>
123 )}
124 <form
125 method="get"
126 action={`/${owner}/${repo}/ai/changelog`}
127 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
128 >
129 <label style="font-size: 13px; color: var(--text-muted)">
130 From
131 </label>
132 <input
133 type="text"
134 name="from"
135 list="ai-changelog-refs"
136 value={from}
137 placeholder="v1.0.0"
138 aria-label="From ref"
139 style="padding: 6px 10px"
140 />
141 <label style="font-size: 13px; color: var(--text-muted)">To</label>
142 <input
143 type="text"
144 name="to"
145 list="ai-changelog-refs"
146 value={to}
147 placeholder="main"
148 aria-label="To ref"
149 style="padding: 6px 10px"
150 />
151 <datalist id="ai-changelog-refs">
152 {refChoices.map((r) => (
153 <option value={r}></option>
154 ))}
155 </datalist>
156 <button type="submit" class="btn btn-primary">
157 Generate
158 </button>
159 </form>
160 {refChoices.length > 0 && (
161 <div style="font-size: 12px; color: var(--text-muted)">
162 Known refs: {refChoices.slice(0, 20).join(", ")}
163 {refChoices.length > 20 ? ", …" : ""}
690
691 {!opts.error && !opts.notice && (
692 <div class="ai-changelog-empty">
693 <div class="ai-changelog-empty-orb" aria-hidden="true" />
694 <h3>Pick a range to start</h3>
695 <p>
696 Two refs (branch / tag / sha) is all Claude needs to write the
697 notes.
698 </p>
699 <div class="ai-changelog-suggests">
700 <div>
701 Try: from <code>v1.0.0</code> to <code>main</code>
702 </div>
703 <div>
704 Or: from <code>HEAD~50</code> to <code>HEAD</code>
705 </div>
706 </div>
707 </div>
708 )}
709
710 <div class="ai-changelog-poweredby">
711 <span class="ai-changelog-poweredby-pill">
712 <span class="dot" aria-hidden="true" />
713 Powered by Claude
714 </span>
164715 </div>
165 )}
716 </div>
717 <style dangerouslySetInnerHTML={{ __html: styles }} />
166718 </Layout>
167719 );
168720
@@ -236,77 +788,155 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
236788 return c.text(markdown, 200, { "Content-Type": "text/markdown" });
237789 }
238790
239 const html = renderMarkdown(markdown);
791 const htmlBody = renderMarkdown(markdown);
240792
241793 return c.html(
242794 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
243795 <RepoHeader owner={owner} repo={repo} />
244796 <IssueNav owner={owner} repo={repo} active="code" />
245 <h2 style="margin-bottom: 4px">AI Changelog</h2>
246 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
247 {from} <span style="opacity: 0.6">..</span> {to} —{" "}
248 {commits.length} commit{commits.length !== 1 ? "s" : ""}
249 </div>
250 <form
251 method="get"
252 action={`/${owner}/${repo}/ai/changelog`}
253 style="display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
254 >
255 <input
256 type="text"
257 name="from"
258 list="ai-changelog-refs"
259 value={from}
260 aria-label="From ref"
261 style="padding: 6px 10px"
262 />
263 <span style="color: var(--text-muted)">..</span>
264 <input
265 type="text"
266 name="to"
267 list="ai-changelog-refs"
268 value={to}
269 aria-label="To ref"
270 style="padding: 6px 10px"
271 />
272 <datalist id="ai-changelog-refs">
273 {refChoices.map((r) => (
274 <option value={r}></option>
275 ))}
276 </datalist>
277 <button type="submit" class="btn">
278 Regenerate
279 </button>
280 <a
281 href={`/${owner}/${repo}/ai/changelog?from=${encodeURIComponent(
282 from
283 )}&to=${encodeURIComponent(to)}&format=markdown`}
284 class="btn"
285 >
286 Raw Markdown
287 </a>
288 </form>
289 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start">
290 <div
291 class="markdown-body"
292 dangerouslySetInnerHTML={{ __html: html }}
293 ></div>
294 <div>
295 <div
296 style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px"
797 <div class="ai-changelog-wrap">
798 <ChangelogHero />
799
800 <div class="ai-changelog-rangeline">
801 <code>{from}</code>
802 <span class="arrow">..</span>
803 <code>{to}</code>
804 <span class="count">
805 {commits.length} commit{commits.length !== 1 ? "s" : ""}
806 </span>
807 </div>
808
809 <div class="ai-changelog-form-card">
810 <form
811 method="get"
812 action={`/${owner}/${repo}/ai/changelog`}
813 class="ai-changelog-form-row"
297814 >
298 Copy Markdown
299 </div>
300 <textarea
301 readonly
302 rows={24}
303 style="width: 100%; font-family: var(--font-mono, monospace); font-size: 12px; padding: 10px; background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); border-radius: 6px"
304 onclick="this.select()"
815 <div class="ai-changelog-field">
816 <label class="ai-changelog-field-label" for="ai-changelog-from-2">
817 From
818 </label>
819 <input
820 id="ai-changelog-from-2"
821 type="text"
822 name="from"
823 list="ai-changelog-refs"
824 value={from}
825 aria-label="From ref"
826 class="ai-changelog-input"
827 />
828 </div>
829 <div class="ai-changelog-field">
830 <label class="ai-changelog-field-label" for="ai-changelog-to-2">
831 To
832 </label>
833 <input
834 id="ai-changelog-to-2"
835 type="text"
836 name="to"
837 list="ai-changelog-refs"
838 value={to}
839 aria-label="To ref"
840 class="ai-changelog-input"
841 />
842 </div>
843 <datalist id="ai-changelog-refs">
844 {refChoices.map((r) => (
845 <option value={r}></option>
846 ))}
847 </datalist>
848 <button type="submit" class="ai-changelog-submit">
849 Regenerate
850 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
851 <polyline points="23 4 23 10 17 10" />
852 <polyline points="1 20 1 14 7 14" />
853 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
854 </svg>
855 </button>
856 <a
857 href={`/${owner}/${repo}/ai/changelog?from=${encodeURIComponent(
858 from
859 )}&to=${encodeURIComponent(to)}&format=markdown`}
860 class="ai-changelog-ghost"
861 >
862 Raw Markdown
863 <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">
864 <path d="M15 3h6v6" />
865 <path d="M10 14L21 3" />
866 <path d="M19 14v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
867 </svg>
868 </a>
869 </form>
870 </div>
871
872 <div class="ai-changelog-results">
873 <section
874 class="ai-changelog-panel"
875 aria-labelledby="ai-changelog-rendered-title"
876 >
877 <header class="ai-changelog-panel-head">
878 <p
879 class="ai-changelog-panel-title"
880 id="ai-changelog-rendered-title"
881 >
882 <span class="ai-changelog-panel-dot" aria-hidden="true" />
883 Release notes · {from} → {to}
884 </p>
885 </header>
886 <div class="ai-changelog-panel-body">
887 <div
888 class="markdown-body"
889 dangerouslySetInnerHTML={{ __html: htmlBody }}
890 ></div>
891 </div>
892 </section>
893
894 <section
895 class="ai-changelog-panel"
896 aria-labelledby="ai-changelog-raw-title"
305897 >
306 {markdown}
307 </textarea>
898 <header class="ai-changelog-panel-head">
899 <p
900 class="ai-changelog-panel-title"
901 id="ai-changelog-raw-title"
902 >
903 <span class="ai-changelog-panel-dot" aria-hidden="true" />
904 Copy Markdown
905 </p>
906 <button
907 type="button"
908 class="ai-changelog-copy"
909 data-ai-changelog-copy
910 aria-label="Copy markdown to clipboard"
911 >
912 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
913 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
914 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
915 </svg>
916 <span data-ai-changelog-copy-label>Copy</span>
917 </button>
918 </header>
919 <textarea
920 readonly
921 rows={24}
922 class="ai-changelog-raw"
923 data-ai-changelog-raw
924 onclick="this.select()"
925 >
926 {markdown}
927 </textarea>
928 </section>
929 </div>
930
931 <div class="ai-changelog-poweredby">
932 <span class="ai-changelog-poweredby-pill">
933 <span class="dot" aria-hidden="true" />
934 Powered by Claude
935 </span>
308936 </div>
309937 </div>
938 <style dangerouslySetInnerHTML={{ __html: styles }} />
939 <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
310940 </Layout>
311941 );
312942});
Modifiedsrc/routes/ai-explain.tsx+563−28View fileUnifiedSplit
@@ -8,6 +8,18 @@
88 * and redirect back
99 *
1010 * Heavy lifting lives in `lib/ai-explain.ts`; this file is just HTTP glue.
11 *
12 * 2026 polish (visual only — every form action / POST target / AI prompt
13 * call is preserved verbatim):
14 * - .ai-explain-wrap max-width 980px + gradient-hairline hero w/ orb
15 * - Display headline ends in a gradient "Explain." verb
16 * - White result panel (mirrors admin-integrations spec block) with
17 * monospace and an inline copy-to-clipboard button
18 * - Loading shimmer skeleton (kept available for future async modes) +
19 * dashed empty-state cards
20 * - "Powered by Claude" subtle pill at the bottom
21 * CSS is scoped under `.ai-explain-*` so it can't bleed into ai-changelog
22 * or ai-tests if they're rendered on the same Layout in another surface.
1123 */
1224
1325import { Hono } from "hono";
@@ -29,6 +41,419 @@ import {
2941
3042const aiExplainRoutes = new Hono<AuthEnv>();
3143
44/* ─────────────────────────────────────────────────────────────────────────
45 * Scoped CSS — every class prefixed `.ai-explain-` so this surface can't
46 * bleed into the wider `.ai-changelog-*` or `.ai-tests-*` polish. Mirrors
47 * the gradient-hairline hero + white-spec-block patterns from
48 * admin-integrations.tsx / build-agent-spec.tsx.
49 * ───────────────────────────────────────────────────────────────────── */
50const styles = `
51 .ai-explain-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
52
53 .ai-explain-hero {
54 position: relative;
55 margin-bottom: var(--space-5);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .ai-explain-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
67 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
68 opacity: 0.7;
69 pointer-events: none;
70 }
71 .ai-explain-hero-orb {
72 position: absolute;
73 inset: -20% -10% auto auto;
74 width: 420px; height: 420px;
75 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
76 filter: blur(80px);
77 opacity: 0.75;
78 pointer-events: none;
79 z-index: 0;
80 }
81 .ai-explain-hero-inner { position: relative; z-index: 1; max-width: 720px; }
82
83 .ai-explain-eyebrow {
84 font-size: 12px;
85 color: var(--text-muted);
86 margin-bottom: var(--space-2);
87 letter-spacing: 0.02em;
88 display: inline-flex;
89 align-items: center;
90 gap: 8px;
91 }
92 .ai-explain-eyebrow .pill {
93 display: inline-flex;
94 align-items: center;
95 justify-content: center;
96 width: 18px; height: 18px;
97 border-radius: 6px;
98 background: rgba(140,109,255,0.14);
99 color: #b69dff;
100 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
101 }
102
103 .ai-explain-title {
104 font-size: clamp(28px, 4vw, 42px);
105 font-family: var(--font-display);
106 font-weight: 800;
107 letter-spacing: -0.028em;
108 line-height: 1.05;
109 margin: 0 0 var(--space-2);
110 color: var(--text-strong);
111 }
112 .ai-explain-title-grad {
113 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
114 -webkit-background-clip: text;
115 background-clip: text;
116 -webkit-text-fill-color: transparent;
117 color: transparent;
118 }
119 .ai-explain-sub {
120 font-size: 15px;
121 color: var(--text-muted);
122 margin: 0;
123 line-height: 1.55;
124 max-width: 620px;
125 }
126
127 .ai-explain-meta {
128 display: flex;
129 align-items: center;
130 gap: 10px;
131 flex-wrap: wrap;
132 font-size: 12px;
133 color: var(--text-muted);
134 margin-bottom: var(--space-3);
135 }
136 .ai-explain-meta code {
137 font-family: var(--font-mono);
138 background: var(--bg-tertiary);
139 padding: 1px 6px;
140 border-radius: 4px;
141 font-size: 11.5px;
142 color: var(--text);
143 }
144 .ai-explain-meta .ai-explain-pill {
145 display: inline-flex;
146 align-items: center;
147 gap: 6px;
148 padding: 2px 8px;
149 border-radius: 9999px;
150 font-size: 10.5px;
151 font-weight: 600;
152 letter-spacing: 0.04em;
153 text-transform: uppercase;
154 background: rgba(52,211,153,0.12);
155 color: #6ee7b7;
156 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
157 }
158 .ai-explain-meta .ai-explain-pill .dot {
159 width: 6px; height: 6px;
160 border-radius: 9999px;
161 background: currentColor;
162 }
163
164 .ai-explain-actions {
165 display: flex;
166 justify-content: space-between;
167 align-items: center;
168 gap: var(--space-2);
169 margin: var(--space-4) 0 var(--space-3);
170 flex-wrap: wrap;
171 }
172 .ai-explain-actions h2 {
173 margin: 0;
174 font-family: var(--font-display);
175 font-size: 18px;
176 font-weight: 700;
177 letter-spacing: -0.018em;
178 color: var(--text-strong);
179 }
180 .ai-explain-regen {
181 display: inline-flex;
182 align-items: center;
183 gap: 6px;
184 padding: 8px 14px;
185 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
186 color: #ffffff;
187 border: 1px solid transparent;
188 border-radius: 10px;
189 font-size: 13px;
190 font-weight: 600;
191 text-decoration: none;
192 cursor: pointer;
193 box-shadow: 0 6px 16px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
194 font-family: inherit;
195 transition: transform 120ms ease, box-shadow 120ms ease;
196 }
197 .ai-explain-regen:hover {
198 transform: translateY(-1px);
199 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
200 }
201 .ai-explain-regen svg { display: block; }
202
203 /* Solid white panel — the codebase explanation reads like a printed
204 report on the dark theme. */
205 .ai-explain-panel {
206 position: relative;
207 margin-bottom: var(--space-5);
208 background: #ffffff;
209 color: #0a0a0a;
210 border: 1px solid #e5e7eb;
211 border-radius: 14px;
212 overflow: hidden;
213 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
214 }
215 .ai-explain-panel-head {
216 display: flex;
217 align-items: center;
218 justify-content: space-between;
219 gap: 12px;
220 padding: 12px 16px;
221 background: #f9fafb;
222 border-bottom: 1px solid #e5e7eb;
223 flex-wrap: wrap;
224 }
225 .ai-explain-panel-title {
226 display: flex;
227 align-items: center;
228 gap: 10px;
229 font-family: var(--font-display, system-ui, sans-serif);
230 font-size: 14px;
231 font-weight: 700;
232 color: #111827;
233 letter-spacing: -0.005em;
234 margin: 0;
235 }
236 .ai-explain-panel-dot {
237 width: 8px; height: 8px;
238 border-radius: 9999px;
239 background: linear-gradient(135deg, #8c6dff, #36c5d6);
240 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
241 }
242 .ai-explain-copy {
243 display: inline-flex;
244 align-items: center;
245 gap: 6px;
246 padding: 6px 12px;
247 font-size: 12.5px;
248 font-weight: 600;
249 color: #111827;
250 background: #ffffff;
251 border: 1px solid #d1d5db;
252 border-radius: 8px;
253 cursor: pointer;
254 font-family: inherit;
255 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
256 }
257 .ai-explain-copy:hover {
258 background: #f3f4f6;
259 border-color: #9ca3af;
260 }
261 .ai-explain-copy.is-copied {
262 background: #ecfdf5;
263 border-color: #6ee7b7;
264 color: #047857;
265 }
266 .ai-explain-copy svg { display: block; }
267
268 .ai-explain-panel-body {
269 padding: 22px 24px;
270 background: #ffffff;
271 color: #0a0a0a;
272 }
273 /* Tame the .markdown-body inside the white panel — its dark-theme
274 defaults (light text on dark bg) would be invisible here. */
275 .ai-explain-panel-body .markdown-body {
276 color: #0a0a0a;
277 background: #ffffff;
278 font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
279 font-size: 14.5px;
280 line-height: 1.65;
281 }
282 .ai-explain-panel-body .markdown-body h1,
283 .ai-explain-panel-body .markdown-body h2,
284 .ai-explain-panel-body .markdown-body h3,
285 .ai-explain-panel-body .markdown-body h4 {
286 color: #0a0a0a;
287 border-bottom-color: #e5e7eb;
288 }
289 .ai-explain-panel-body .markdown-body a { color: #4338ca; }
290 .ai-explain-panel-body .markdown-body code {
291 background: #eef2ff;
292 color: #4338ca;
293 padding: 1px 5px;
294 border-radius: 4px;
295 font-family: var(--font-mono);
296 font-size: 12.5px;
297 }
298 .ai-explain-panel-body .markdown-body pre {
299 background: #0f111a;
300 color: #e6edf3;
301 border: 1px solid #1f2330;
302 border-radius: 8px;
303 padding: 12px 14px;
304 overflow-x: auto;
305 }
306 .ai-explain-panel-body .markdown-body pre code {
307 background: transparent;
308 color: inherit;
309 padding: 0;
310 }
311 .ai-explain-panel-body .markdown-body blockquote {
312 border-left: 3px solid #c7d2fe;
313 background: #f5f3ff;
314 color: #4b5563;
315 padding: 8px 14px;
316 margin: 12px 0;
317 border-radius: 6px;
318 }
319
320 /* Empty-state — dashed card w/ orb + "try" suggestions. */
321 .ai-explain-empty {
322 position: relative;
323 margin: var(--space-4) 0;
324 padding: var(--space-6);
325 border: 1px dashed var(--border-strong, var(--border));
326 border-radius: 14px;
327 background: var(--bg-elevated);
328 text-align: center;
329 overflow: hidden;
330 }
331 .ai-explain-empty-orb {
332 position: absolute;
333 inset: -40% 35% auto 35%;
334 width: 280px; height: 280px;
335 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.08) 45%, transparent 70%);
336 filter: blur(60px);
337 pointer-events: none;
338 z-index: 0;
339 }
340 .ai-explain-empty > * { position: relative; z-index: 1; }
341 .ai-explain-empty h2 {
342 margin: 0 0 6px;
343 font-family: var(--font-display);
344 font-size: 19px;
345 color: var(--text-strong);
346 }
347 .ai-explain-empty p {
348 margin: 0 auto 12px;
349 color: var(--text-muted);
350 font-size: 14px;
351 max-width: 480px;
352 line-height: 1.55;
353 }
354 .ai-explain-empty .ai-explain-suggest {
355 display: inline-flex;
356 flex-direction: column;
357 align-items: flex-start;
358 gap: 6px;
359 margin-top: 12px;
360 text-align: left;
361 font-size: 12.5px;
362 color: var(--text-muted);
363 }
364 .ai-explain-empty .ai-explain-suggest code {
365 font-family: var(--font-mono);
366 background: var(--bg-tertiary);
367 padding: 2px 7px;
368 border-radius: 4px;
369 color: var(--text);
370 }
371
372 /* Powered-by-Claude pill at the bottom. */
373 .ai-explain-poweredby {
374 margin-top: var(--space-5);
375 text-align: center;
376 color: var(--text-muted);
377 font-size: 11.5px;
378 }
379 .ai-explain-poweredby-pill {
380 display: inline-flex;
381 align-items: center;
382 gap: 6px;
383 padding: 4px 10px;
384 border-radius: 9999px;
385 background: rgba(140,109,255,0.08);
386 border: 1px solid rgba(140,109,255,0.22);
387 color: var(--text-muted);
388 font-size: 11px;
389 letter-spacing: 0.04em;
390 text-transform: uppercase;
391 font-weight: 600;
392 }
393 .ai-explain-poweredby-pill .dot {
394 width: 6px; height: 6px;
395 border-radius: 9999px;
396 background: linear-gradient(135deg, #8c6dff, #36c5d6);
397 }
398
399 /* Loading shimmer skeleton — kept available for future async modes. */
400 @keyframes ai-explain-shimmer {
401 0% { background-position: -300px 0; }
402 100% { background-position: 300px 0; }
403 }
404 .ai-explain-skeleton {
405 height: 14px;
406 border-radius: 4px;
407 background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(140,109,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
408 background-size: 600px 100%;
409 animation: ai-explain-shimmer 1.4s linear infinite;
410 margin-bottom: 10px;
411 }
412
413 /* Light-theme: the white panel already pops; just soften the shadow. */
414 :root[data-theme='light'] .ai-explain-panel {
415 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
416 }
417`;
418
419// Inline copy-to-clipboard handler — reuses the data-attr pattern from
420// admin-integrations.tsx. Safe to embed because listeners are attached by
421// data-attr selector.
422const COPY_SCRIPT = `
423 (function(){
424 var btn = document.querySelector('[data-ai-explain-copy]');
425 var src = document.querySelector('[data-ai-explain-text]');
426 var label = document.querySelector('[data-ai-explain-copy-label]');
427 if (!btn || !src || !label) return;
428 btn.addEventListener('click', function(){
429 var text = src.innerText || src.textContent || '';
430 var done = function(){
431 btn.classList.add('is-copied');
432 label.textContent = 'Copied';
433 setTimeout(function(){
434 btn.classList.remove('is-copied');
435 label.textContent = 'Copy';
436 }, 1800);
437 };
438 if (navigator.clipboard && navigator.clipboard.writeText) {
439 navigator.clipboard.writeText(text).then(done).catch(function(){
440 var ta = document.createElement('textarea');
441 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
442 document.body.appendChild(ta); ta.select();
443 try { document.execCommand('copy'); done(); } catch(e){}
444 document.body.removeChild(ta);
445 });
446 } else {
447 var ta = document.createElement('textarea');
448 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
449 document.body.appendChild(ta); ta.select();
450 try { document.execCommand('copy'); done(); } catch(e){}
451 document.body.removeChild(ta);
452 }
453 });
454 })();
455`;
456
32457interface ResolvedRepo {
33458 ownerId: string;
34459 ownerUsername: string;
@@ -103,13 +528,48 @@ aiExplainRoutes.get(
103528 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
104529 <RepoHeader owner={owner} repo={repo} />
105530 <IssueNav owner={owner} repo={repo} active="code" />
106 <div class="empty-state">
107 <h2>No commits yet</h2>
108 <p>
109 Push some code to <code>{repo}</code> and check back — the
110 explanation is generated from the default branch.
111 </p>
531 <div class="ai-explain-wrap">
532 <section class="ai-explain-hero">
533 <div class="ai-explain-hero-orb" aria-hidden="true" />
534 <div class="ai-explain-hero-inner">
535 <div class="ai-explain-eyebrow">
536 <span class="pill" aria-hidden="true">
537 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
538 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
539 </svg>
540 </span>
541 AI · gluecron · explain
542 </div>
543 <h1 class="ai-explain-title">
544 <span class="ai-explain-title-grad">Explain.</span>
545 </h1>
546 <p class="ai-explain-sub">
547 Once you push code to <code style="font-family:var(--font-mono);font-size:13px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">{repo}</code>,
548 Claude will read the default branch and write a plain-English
549 tour of the architecture, key modules, and how to get started.
550 </p>
551 </div>
552 </section>
553 <div class="ai-explain-empty">
554 <div class="ai-explain-empty-orb" aria-hidden="true" />
555 <h2>No commits yet</h2>
556 <p>
557 Push some code to <code>{repo}</code> and check back — the
558 explanation is generated from the default branch.
559 </p>
560 <div class="ai-explain-suggest">
561 <span>Try:</span>
562 <code>git push origin main</code>
563 </div>
564 </div>
565 <div class="ai-explain-poweredby">
566 <span class="ai-explain-poweredby-pill">
567 <span class="dot" aria-hidden="true" />
568 Powered by Claude
569 </span>
570 </div>
112571 </div>
572 <style dangerouslySetInnerHTML={{ __html: styles }} />
113573 </Layout>
114574 );
115575 }
@@ -131,30 +591,105 @@ aiExplainRoutes.get(
131591 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
132592 <RepoHeader owner={owner} repo={repo} />
133593 <IssueNav owner={owner} repo={repo} active="code" />
134 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
135 <h2 style="margin: 0;">Codebase explanation</h2>
136 {canRegenerate && (
137 <form
138 method="post"
139 action={`/${owner}/${repo}/explain/regenerate`}
140 style="display: inline"
141 >
142 <button type="submit" class="star-btn">
143 Regenerate
594 <div class="ai-explain-wrap">
595 <section class="ai-explain-hero">
596 <div class="ai-explain-hero-orb" aria-hidden="true" />
597 <div class="ai-explain-hero-inner">
598 <div class="ai-explain-eyebrow">
599 <span class="pill" aria-hidden="true">
600 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
601 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
602 </svg>
603 </span>
604 AI · gluecron · explain
605 </div>
606 <h1 class="ai-explain-title">
607 <span class="ai-explain-title-grad">Explain.</span>{" "}
608 <span style="color:var(--text-strong)">
609 {owner}/{repo}
610 </span>
611 </h1>
612 <p class="ai-explain-sub">
613 A plain-English tour of this codebase — architecture, key
614 modules, and how to get started. Generated by Claude from the
615 default branch.
616 </p>
617 </div>
618 </section>
619
620 <div class="ai-explain-meta">
621 <span>
622 Commit <code>{sha.slice(0, 7)}</code>
623 </span>
624 <span>·</span>
625 <span>
626 Model <code>{result.model}</code>
627 </span>
628 {result.cached && (
629 <span class="ai-explain-pill">
630 <span class="dot" aria-hidden="true" />
631 cached
632 </span>
633 )}
634 </div>
635
636 <div class="ai-explain-actions">
637 <h2>Codebase explanation</h2>
638 {canRegenerate && (
639 <form
640 method="post"
641 action={`/${owner}/${repo}/explain/regenerate`}
642 style="display: inline"
643 >
644 <button type="submit" class="ai-explain-regen">
645 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
646 <polyline points="23 4 23 10 17 10" />
647 <polyline points="1 20 1 14 7 14" />
648 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
649 </svg>
650 Regenerate
651 </button>
652 </form>
653 )}
654 </div>
655
656 <section class="ai-explain-panel" aria-labelledby="ai-explain-panel-title">
657 <header class="ai-explain-panel-head">
658 <p class="ai-explain-panel-title" id="ai-explain-panel-title">
659 <span class="ai-explain-panel-dot" aria-hidden="true" />
660 Explanation · {owner}/{repo}
661 </p>
662 <button
663 type="button"
664 class="ai-explain-copy"
665 data-ai-explain-copy
666 aria-label="Copy explanation to clipboard"
667 >
668 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
669 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
670 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
671 </svg>
672 <span data-ai-explain-copy-label>Copy</span>
144673 </button>
145 </form>
146 )}
147 </div>
148 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">
149 Generated from commit <code>{sha.slice(0, 7)}</code> · model{" "}
150 <code>{result.model}</code>
151 {result.cached ? " · cached" : ""}
152 </div>
153 <div class="markdown-body">
154 {html(
155 [renderMarkdown(result.markdown)] as unknown as TemplateStringsArray
156 )}
674 </header>
675 <div class="ai-explain-panel-body" data-ai-explain-text>
676 <div class="markdown-body">
677 {html(
678 [renderMarkdown(result.markdown)] as unknown as TemplateStringsArray
679 )}
680 </div>
681 </div>
682 </section>
683
684 <div class="ai-explain-poweredby">
685 <span class="ai-explain-poweredby-pill">
686 <span class="dot" aria-hidden="true" />
687 Powered by Claude
688 </span>
689 </div>
157690 </div>
691 <style dangerouslySetInnerHTML={{ __html: styles }} />
692 <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
158693 </Layout>
159694 );
160695 }
Modifiedsrc/routes/ai-tests.tsx+699−152View fileUnifiedSplit
@@ -15,10 +15,15 @@
1515 * Auth required. Actually runs the model and renders the result
1616 * page with highlighted source, highlighted test, a copy-to-clipboard
1717 * button, a review warning, and a regenerate button.
18 *
19 * 2026 polish — visual only. Every form action, POST target, raw-format
20 * branch, `generateTestStub(...)` AI call, and the highlightCode pipeline
21 * are preserved verbatim. CSS scoped under `.ai-tests-*` so it can't
22 * collide with `.ai-explain-*` or `.ai-changelog-*` on a shared Layout.
1823 */
1924
2025import { Hono } from "hono";
21import { html, raw } from "hono/html";
26import { raw } from "hono/html";
2227import { and, eq } from "drizzle-orm";
2328import { db } from "../db";
2429import { repositories, users } from "../db/schema";
@@ -44,6 +49,452 @@ import {
4449
4550const aiTestsRoutes = new Hono<AuthEnv>();
4651
52/* ─────────────────────────────────────────────────────────────────────────
53 * Scoped CSS — every class prefixed `.ai-tests-` so this surface stays
54 * isolated from `.ai-explain-*` and `.ai-changelog-*`. Mirrors the
55 * gradient-hairline hero + white spec-block patterns from
56 * admin-integrations.tsx / build-agent-spec.tsx, with focus-rings on
57 * inputs via the :root --border-focus token.
58 * ───────────────────────────────────────────────────────────────────── */
59const styles = `
60 .ai-tests-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
61
62 .ai-tests-hero {
63 position: relative;
64 margin-bottom: var(--space-5);
65 padding: var(--space-5) var(--space-6);
66 background: var(--bg-elevated);
67 border: 1px solid var(--border);
68 border-radius: 16px;
69 overflow: hidden;
70 }
71 .ai-tests-hero::before {
72 content: '';
73 position: absolute;
74 top: 0; left: 0; right: 0;
75 height: 2px;
76 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
77 opacity: 0.7;
78 pointer-events: none;
79 }
80 .ai-tests-hero-orb {
81 position: absolute;
82 inset: -20% -10% auto auto;
83 width: 420px; height: 420px;
84 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
85 filter: blur(80px);
86 opacity: 0.75;
87 pointer-events: none;
88 z-index: 0;
89 }
90 .ai-tests-hero-inner { position: relative; z-index: 1; max-width: 720px; }
91 .ai-tests-eyebrow {
92 font-size: 12px;
93 color: var(--text-muted);
94 margin-bottom: var(--space-2);
95 letter-spacing: 0.02em;
96 display: inline-flex;
97 align-items: center;
98 gap: 8px;
99 }
100 .ai-tests-eyebrow .pill {
101 display: inline-flex;
102 align-items: center;
103 justify-content: center;
104 width: 18px; height: 18px;
105 border-radius: 6px;
106 background: rgba(140,109,255,0.14);
107 color: #b69dff;
108 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
109 }
110 .ai-tests-title {
111 font-size: clamp(28px, 4vw, 42px);
112 font-family: var(--font-display);
113 font-weight: 800;
114 letter-spacing: -0.028em;
115 line-height: 1.05;
116 margin: 0 0 var(--space-2);
117 color: var(--text-strong);
118 }
119 .ai-tests-title-grad {
120 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
121 -webkit-background-clip: text;
122 background-clip: text;
123 -webkit-text-fill-color: transparent;
124 color: transparent;
125 }
126 .ai-tests-sub {
127 font-size: 15px;
128 color: var(--text-muted);
129 margin: 0;
130 line-height: 1.55;
131 max-width: 640px;
132 }
133 .ai-tests-sub strong { color: var(--text-strong); font-weight: 700; }
134 .ai-tests-sub code {
135 font-family: var(--font-mono);
136 background: var(--bg-tertiary);
137 color: var(--text);
138 padding: 1px 5px;
139 border-radius: 4px;
140 font-size: 12.5px;
141 }
142
143 /* Form card */
144 .ai-tests-form-card {
145 margin-bottom: var(--space-5);
146 background: var(--bg-elevated);
147 border: 1px solid var(--border);
148 border-radius: 14px;
149 padding: var(--space-4) var(--space-5);
150 }
151 .ai-tests-field {
152 display: flex;
153 flex-direction: column;
154 gap: 6px;
155 margin-bottom: var(--space-3);
156 }
157 .ai-tests-field-label {
158 font-size: 11px;
159 font-weight: 700;
160 text-transform: uppercase;
161 letter-spacing: 0.12em;
162 color: var(--text-muted);
163 }
164 .ai-tests-input,
165 .ai-tests-select {
166 width: 100%;
167 padding: 9px 12px;
168 font-size: 13.5px;
169 color: var(--text);
170 background: var(--bg);
171 border: 1px solid var(--border-strong);
172 border-radius: 8px;
173 outline: none;
174 font-family: var(--font-mono);
175 transition: border-color 120ms ease, box-shadow 120ms ease;
176 box-sizing: border-box;
177 }
178 .ai-tests-input:focus,
179 .ai-tests-select:focus {
180 border-color: var(--border-focus);
181 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
182 }
183 .ai-tests-submit {
184 display: inline-flex;
185 align-items: center;
186 gap: 7px;
187 padding: 9px 16px;
188 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
189 color: #ffffff;
190 border: 1px solid transparent;
191 border-radius: 10px;
192 font-size: 13.5px;
193 font-weight: 600;
194 cursor: pointer;
195 box-shadow: 0 6px 16px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
196 font-family: inherit;
197 transition: transform 120ms ease, box-shadow 120ms ease;
198 line-height: 1;
199 }
200 .ai-tests-submit:hover {
201 transform: translateY(-1px);
202 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
203 }
204 .ai-tests-submit svg { display: block; }
205
206 .ai-tests-detected {
207 display: inline-flex;
208 gap: 12px;
209 margin-top: 6px;
210 font-size: 12px;
211 color: var(--text-muted);
212 flex-wrap: wrap;
213 }
214 .ai-tests-detected code {
215 font-family: var(--font-mono);
216 background: var(--bg-tertiary);
217 color: var(--text);
218 padding: 1px 6px;
219 border-radius: 4px;
220 font-size: 11.5px;
221 }
222
223 /* Header bar between hero + results */
224 .ai-tests-resulthead {
225 display: flex;
226 justify-content: space-between;
227 align-items: center;
228 gap: var(--space-2);
229 margin: var(--space-4) 0 var(--space-3);
230 flex-wrap: wrap;
231 }
232 .ai-tests-resulthead h2 {
233 margin: 0 0 4px;
234 font-family: var(--font-display);
235 font-size: 18px;
236 font-weight: 700;
237 letter-spacing: -0.018em;
238 color: var(--text-strong);
239 }
240 .ai-tests-resulthead h2 code {
241 font-family: var(--font-mono);
242 background: var(--bg-tertiary);
243 color: var(--text);
244 padding: 1px 6px;
245 border-radius: 4px;
246 font-size: 14px;
247 font-weight: 600;
248 }
249 .ai-tests-regen {
250 display: inline-flex;
251 align-items: center;
252 gap: 6px;
253 padding: 8px 14px;
254 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
255 color: #ffffff;
256 border: 1px solid transparent;
257 border-radius: 10px;
258 font-size: 13px;
259 font-weight: 600;
260 cursor: pointer;
261 box-shadow: 0 6px 16px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
262 font-family: inherit;
263 transition: transform 120ms ease, box-shadow 120ms ease;
264 }
265 .ai-tests-regen:hover {
266 transform: translateY(-1px);
267 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
268 }
269 .ai-tests-regen svg { display: block; }
270
271 /* Banner — review warning */
272 .ai-tests-warning {
273 margin-bottom: var(--space-4);
274 padding: 12px 14px;
275 border-radius: 12px;
276 background: rgba(210, 153, 34, 0.10);
277 border: 1px solid rgba(210, 153, 34, 0.40);
278 color: #fde68a;
279 font-size: 13.5px;
280 line-height: 1.55;
281 }
282 .ai-tests-warning strong { color: #fef3c7; }
283 .ai-tests-warning em { color: #fde68a; font-style: italic; }
284
285 /* Source + test panels — white container, dark code inside (matches the
286 existing .hljs styling on the dark theme). */
287 .ai-tests-section { margin-bottom: var(--space-5); }
288 .ai-tests-panel {
289 position: relative;
290 background: #ffffff;
291 color: #0a0a0a;
292 border: 1px solid #e5e7eb;
293 border-radius: 14px;
294 overflow: hidden;
295 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
296 }
297 .ai-tests-panel-head {
298 display: flex;
299 align-items: center;
300 justify-content: space-between;
301 gap: 12px;
302 padding: 12px 16px;
303 background: #f9fafb;
304 border-bottom: 1px solid #e5e7eb;
305 flex-wrap: wrap;
306 }
307 .ai-tests-panel-title {
308 display: flex;
309 align-items: center;
310 gap: 10px;
311 font-family: var(--font-display, system-ui, sans-serif);
312 font-size: 13.5px;
313 font-weight: 700;
314 color: #111827;
315 letter-spacing: -0.005em;
316 margin: 0;
317 }
318 .ai-tests-panel-title code {
319 font-family: var(--font-mono);
320 background: #eef2ff;
321 color: #4338ca;
322 padding: 1px 6px;
323 border-radius: 4px;
324 font-size: 12px;
325 }
326 .ai-tests-panel-dot {
327 width: 8px; height: 8px;
328 border-radius: 9999px;
329 background: linear-gradient(135deg, #8c6dff, #36c5d6);
330 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
331 }
332 .ai-tests-copy {
333 display: inline-flex;
334 align-items: center;
335 gap: 6px;
336 padding: 6px 12px;
337 font-size: 12.5px;
338 font-weight: 600;
339 color: #111827;
340 background: #ffffff;
341 border: 1px solid #d1d5db;
342 border-radius: 8px;
343 cursor: pointer;
344 font-family: inherit;
345 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
346 }
347 .ai-tests-copy:hover { background: #f3f4f6; border-color: #9ca3af; }
348 .ai-tests-copy.is-copied {
349 background: #ecfdf5;
350 border-color: #6ee7b7;
351 color: #047857;
352 }
353 .ai-tests-copy svg { display: block; }
354
355 .ai-tests-pre {
356 margin: 0;
357 padding: 14px 16px;
358 background: #0f111a;
359 color: #e6edf3;
360 font-family: var(--font-mono);
361 font-size: 12.5px;
362 line-height: 1.6;
363 overflow: auto;
364 white-space: pre;
365 max-height: 70vh;
366 }
367 .ai-tests-pre code { background: transparent; color: inherit; padding: 0; }
368
369 /* Empty-state — dashed card w/ orb + suggestion prompts. */
370 .ai-tests-empty {
371 position: relative;
372 margin: var(--space-4) 0;
373 padding: var(--space-5);
374 border: 1px dashed var(--border-strong, var(--border));
375 border-radius: 14px;
376 background: var(--bg-elevated);
377 overflow: hidden;
378 text-align: center;
379 }
380 .ai-tests-empty-orb {
381 position: absolute;
382 inset: -50% 30% auto 30%;
383 width: 320px; height: 320px;
384 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.06) 45%, transparent 70%);
385 filter: blur(70px);
386 pointer-events: none;
387 z-index: 0;
388 }
389 .ai-tests-empty > * { position: relative; z-index: 1; }
390 .ai-tests-empty h3 {
391 margin: 0 0 6px;
392 font-family: var(--font-display);
393 font-size: 16px;
394 color: var(--text-strong);
395 }
396 .ai-tests-empty p {
397 margin: 0 0 6px;
398 color: var(--text-muted);
399 font-size: 13px;
400 }
401 .ai-tests-suggests {
402 display: inline-flex;
403 flex-direction: column;
404 gap: 6px;
405 margin-top: 10px;
406 font-size: 12px;
407 color: var(--text-muted);
408 text-align: left;
409 }
410 .ai-tests-suggests code {
411 font-family: var(--font-mono);
412 background: var(--bg-tertiary);
413 color: var(--text);
414 padding: 2px 7px;
415 border-radius: 4px;
416 font-size: 11.5px;
417 }
418
419 /* Loading shimmer skeleton — kept for future async modes. */
420 @keyframes ai-tests-shimmer {
421 0% { background-position: -300px 0; }
422 100% { background-position: 300px 0; }
423 }
424 .ai-tests-skeleton {
425 height: 12px;
426 border-radius: 4px;
427 background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(140,109,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
428 background-size: 600px 100%;
429 animation: ai-tests-shimmer 1.4s linear infinite;
430 margin-bottom: 8px;
431 }
432
433 /* Powered by Claude pill */
434 .ai-tests-poweredby {
435 margin-top: var(--space-5);
436 text-align: center;
437 }
438 .ai-tests-poweredby-pill {
439 display: inline-flex;
440 align-items: center;
441 gap: 6px;
442 padding: 4px 10px;
443 border-radius: 9999px;
444 background: rgba(140,109,255,0.08);
445 border: 1px solid rgba(140,109,255,0.22);
446 color: var(--text-muted);
447 font-size: 11px;
448 letter-spacing: 0.04em;
449 text-transform: uppercase;
450 font-weight: 600;
451 }
452 .ai-tests-poweredby-pill .dot {
453 width: 6px; height: 6px;
454 border-radius: 9999px;
455 background: linear-gradient(135deg, #8c6dff, #36c5d6);
456 }
457
458 :root[data-theme='light'] .ai-tests-panel {
459 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
460 }
461`;
462
463const COPY_SCRIPT = `
464 (function(){
465 var btn = document.querySelector('[data-ai-tests-copy]');
466 var src = document.querySelector('[data-ai-tests-code]');
467 var label = document.querySelector('[data-ai-tests-copy-label]');
468 if (!btn || !src || !label) return;
469 btn.addEventListener('click', function(){
470 var text = src.innerText || src.textContent || '';
471 var done = function(){
472 btn.classList.add('is-copied');
473 label.textContent = 'Copied';
474 setTimeout(function(){
475 btn.classList.remove('is-copied');
476 label.textContent = 'Copy';
477 }, 1800);
478 };
479 if (navigator.clipboard && navigator.clipboard.writeText) {
480 navigator.clipboard.writeText(text).then(done).catch(function(){
481 var ta = document.createElement('textarea');
482 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
483 document.body.appendChild(ta); ta.select();
484 try { document.execCommand('copy'); done(); } catch(e){}
485 document.body.removeChild(ta);
486 });
487 } else {
488 var ta = document.createElement('textarea');
489 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
490 document.body.appendChild(ta); ta.select();
491 try { document.execCommand('copy'); done(); } catch(e){}
492 document.body.removeChild(ta);
493 }
494 });
495 })();
496`;
497
47498interface ResolvedRepo {
48499 ownerId: string;
49500 ownerUsername: string;
@@ -139,6 +590,35 @@ async function listRepoFiles(
139590 return out;
140591}
141592
593function TestsHero(props: { eyebrowExtra?: string }) {
594 return (
595 <section class="ai-tests-hero">
596 <div class="ai-tests-hero-orb" aria-hidden="true" />
597 <div class="ai-tests-hero-inner">
598 <div class="ai-tests-eyebrow">
599 <span class="pill" aria-hidden="true">
600 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
601 <polyline points="9 11 12 14 22 4" />
602 <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
603 </svg>
604 </span>
605 AI · gluecron · tests{props.eyebrowExtra ? ` · ${props.eyebrowExtra}` : ""}
606 </div>
607 <h1 class="ai-tests-title">
608 <span class="ai-tests-title-grad">Generate.</span>{" "}
609 <span style="color:var(--text-strong)">AI tests</span>
610 </h1>
611 <p class="ai-tests-sub">
612 Pick a source file and gluecron will ask Claude to draft a{" "}
613 <strong>failing</strong> test stub that exercises its public surface.
614 Treat the output as a starting-point — always review before
615 committing.
616 </p>
617 </div>
618 </section>
619 );
620}
621
142622function renderPicker(
143623 ownerName: string,
144624 repoName: string,
@@ -148,47 +628,58 @@ function renderPicker(
148628) {
149629 const trimmed = allFiles.slice(0, 200);
150630 return (
151 <form
152 method="post"
153 action={`/${ownerName}/${repoName}/ai/tests/generate`}
154 style="margin-top: 16px; display: flex; flex-direction: column; gap: 12px; max-width: 720px;"
155 >
156 <input type="hidden" name="ref" value={ref} />
157 <label style="display: flex; flex-direction: column; gap: 6px;">
158 <span style="font-weight: 600;">Source file</span>
159 <input
160 type="text"
161 name="path"
162 value={currentPath}
163 placeholder="src/lib/foo.ts"
164 required
165 aria-label="Source file"
166 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
167 />
168 </label>
169 {trimmed.length > 0 && (
170 <label style="display: flex; flex-direction: column; gap: 6px;">
171 <span style="font-weight: 600;">…or pick from the repo</span>
172 <select
173 name="pickPath"
174 onchange="this.form.path.value = this.value"
175 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
176 >
177 <option value="">— select a file —</option>
178 {trimmed.map((f) => (
179 <option value={f} selected={f === currentPath}>
180 {f}
181 </option>
182 ))}
183 </select>
184 </label>
185 )}
186 <div>
187 <button type="submit" class="star-btn" style="padding: 6px 14px;">
188 Generate tests
189 </button>
190 </div>
191 </form>
631 <div class="ai-tests-form-card">
632 <form
633 method="post"
634 action={`/${ownerName}/${repoName}/ai/tests/generate`}
635 >
636 <input type="hidden" name="ref" value={ref} />
637 <div class="ai-tests-field">
638 <label class="ai-tests-field-label" for="ai-tests-path">
639 Source file
640 </label>
641 <input
642 id="ai-tests-path"
643 type="text"
644 name="path"
645 value={currentPath}
646 placeholder="src/lib/foo.ts"
647 required
648 aria-label="Source file"
649 class="ai-tests-input"
650 />
651 </div>
652 {trimmed.length > 0 && (
653 <div class="ai-tests-field">
654 <label class="ai-tests-field-label" for="ai-tests-pick">
655 …or pick from the repo
656 </label>
657 <select
658 id="ai-tests-pick"
659 name="pickPath"
660 onchange="this.form.path.value = this.value"
661 class="ai-tests-select"
662 >
663 <option value="">— select a file —</option>
664 {trimmed.map((f) => (
665 <option value={f} selected={f === currentPath}>
666 {f}
667 </option>
668 ))}
669 </select>
670 </div>
671 )}
672 <div>
673 <button type="submit" class="ai-tests-submit">
674 Generate tests
675 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
676 <line x1="5" y1="12" x2="19" y2="12" />
677 <polyline points="12 5 19 12 12 19" />
678 </svg>
679 </button>
680 </div>
681 </form>
682 </div>
192683 );
193684}
194685
@@ -251,22 +742,49 @@ aiTestsRoutes.get("/:owner/:repo/ai/tests", softAuth, async (c) => {
251742 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
252743 <RepoHeader owner={owner} repo={repo} />
253744 <IssueNav owner={owner} repo={repo} active="code" />
254 <div style="margin: 16px 0;">
255 <h2 style="margin: 0 0 8px;">AI-generated tests</h2>
256 <p style="color: var(--text-muted); margin: 0;">
257 Pick a source file and gluecron will ask Claude to draft a{" "}
258 <strong>failing</strong> test stub that exercises its public surface.
259 Treat the output as a starting-point — always review before
260 committing.
261 </p>
745 <div class="ai-tests-wrap">
746 <TestsHero />
747
262748 {path && (
263 <p style="color: var(--text-muted); margin: 8px 0 0; font-size: 13px;">
264 Detected language: <code>{detectedLang}</code> · framework:{" "}
265 <code>{detectedFramework}</code>
266 </p>
749 <div class="ai-tests-detected">
750 <span>
751 Detected language: <code>{detectedLang}</code>
752 </span>
753 <span>
754 Framework: <code>{detectedFramework}</code>
755 </span>
756 </div>
757 )}
758
759 {renderPicker(owner, repo, repoFiles, path, ref)}
760
761 {!path && (
762 <div class="ai-tests-empty">
763 <div class="ai-tests-empty-orb" aria-hidden="true" />
764 <h3>No file picked yet</h3>
765 <p>
766 Choose any source file in this repo — Claude will draft a failing
767 test stub matching your detected framework.
768 </p>
769 <div class="ai-tests-suggests">
770 <div>
771 Try: <code>src/lib/auth.ts</code>
772 </div>
773 <div>
774 Or: <code>src/git/repository.ts</code>
775 </div>
776 </div>
777 </div>
267778 )}
779
780 <div class="ai-tests-poweredby">
781 <span class="ai-tests-poweredby-pill">
782 <span class="dot" aria-hidden="true" />
783 Powered by Claude
784 </span>
785 </div>
268786 </div>
269 {renderPicker(owner, repo, repoFiles, path, ref)}
787 <style dangerouslySetInnerHTML={{ __html: styles }} />
270788 </Layout>
271789 );
272790});
@@ -307,15 +825,26 @@ aiTestsRoutes.post(
307825 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
308826 <RepoHeader owner={owner} repo={repo} />
309827 <IssueNav owner={owner} repo={repo} active="code" />
310 <div class="empty-state">
311 <h2>Couldn't read that file</h2>
312 <p>
313 No such path at <code>{ref}</code>, or the file is binary.
314 </p>
315 <p>
316 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
317 </p>
828 <div class="ai-tests-wrap">
829 <TestsHero eyebrowExtra="error" />
830 <div class="ai-tests-empty">
831 <div class="ai-tests-empty-orb" aria-hidden="true" />
832 <h3>Couldn't read that file</h3>
833 <p>
834 No such path at <code>{ref}</code>, or the file is binary.
835 </p>
836 <p>
837 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
838 </p>
839 </div>
840 <div class="ai-tests-poweredby">
841 <span class="ai-tests-poweredby-pill">
842 <span class="dot" aria-hidden="true" />
843 Powered by Claude
844 </span>
845 </div>
318846 </div>
847 <style dangerouslySetInnerHTML={{ __html: styles }} />
319848 </Layout>,
320849 404
321850 );
@@ -341,100 +870,118 @@ aiTestsRoutes.post(
341870 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
342871 <RepoHeader owner={owner} repo={repo} />
343872 <IssueNav owner={owner} repo={repo} active="code" />
344 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
345 <div>
346 <h2 style="margin: 0;">AI-generated tests for <code>{path}</code></h2>
347 <p style="color: var(--text-muted); margin: 4px 0 0; font-size: 13px;">
348 Detected language: <code>{language}</code> · framework:{" "}
349 <code>{aiFailed ? "fallback" : framework}</code> · ref{" "}
350 <code>{ref}</code>
351 </p>
352 </div>
353 <form
354 method="post"
355 action={`/${owner}/${repo}/ai/tests/generate`}
356 style="display: inline;"
357 >
358 <input type="hidden" name="path" value={path} />
359 <input type="hidden" name="ref" value={ref} />
360 <button type="submit" class="star-btn">Regenerate</button>
361 </form>
362 </div>
873 <div class="ai-tests-wrap">
874 <TestsHero eyebrowExtra={path} />
363875
364 <div
365 class="flash-warning"
366 style="border: 1px solid var(--border); background: rgba(210, 153, 34, 0.12); padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;"
367 >
368 <strong>Review before committing.</strong> These tests are a
369 starting-point only — they are intentionally written to{" "}
370 <em>fail</em> so you are forced to supply real expected values.
371 Gluecron does not verify the behaviour is correct.
372 </div>
876 <div class="ai-tests-resulthead">
877 <div>
878 <h2>
879 Tests for <code>{path}</code>
880 </h2>
881 <div class="ai-tests-detected">
882 <span>
883 Language: <code>{language}</code>
884 </span>
885 <span>
886 Framework: <code>{aiFailed ? "fallback" : framework}</code>
887 </span>
888 <span>
889 Ref: <code>{ref}</code>
890 </span>
891 </div>
892 </div>
893 <form
894 method="post"
895 action={`/${owner}/${repo}/ai/tests/generate`}
896 style="display: inline;"
897 >
898 <input type="hidden" name="path" value={path} />
899 <input type="hidden" name="ref" value={ref} />
900 <button type="submit" class="ai-tests-regen">
901 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
902 <polyline points="23 4 23 10 17 10" />
903 <polyline points="1 20 1 14 7 14" />
904 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
905 </svg>
906 Regenerate
907 </button>
908 </form>
909 </div>
373910
374 {aiFailed && (
375 <div
376 class="empty-state"
377 style="border: 1px dashed var(--border); padding: 16px; border-radius: 6px; margin-bottom: 16px;"
378 >
379 <p style="margin: 0;">
380 Couldn't generate a test stub. The AI backend may not be
381 configured, or the model returned an empty response. A suggested
382 path was still computed: <code>{result.suggestedPath}</code>.
383 </p>
911 <div class="ai-tests-warning flash-warning">
912 <strong>Review before committing.</strong> These tests are a
913 starting-point only — they are intentionally written to{" "}
914 <em>fail</em> so you are forced to supply real expected values.
915 Gluecron does not verify the behaviour is correct.
384916 </div>
385 )}
386917
387 <section style="margin-bottom: 24px;">
388 <h3 style="margin: 0 0 8px; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
389 Source — <code>{path}</code>
390 </h3>
391 <pre class="hljs" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto;">
392 <code>{raw(sourceHl.html)}</code>
393 </pre>
394 </section>
395
396 <section>
397 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
398 <h3 style="margin: 0; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
399 Suggested test — <code>{result.suggestedPath}</code>
400 </h3>
401 <button
402 type="button"
403 class="star-btn"
404 id="copy-test-btn"
405 data-test-code-id="ai-test-code"
406 >
407 Copy
408 </button>
918 {aiFailed && (
919 <div class="ai-tests-empty">
920 <div class="ai-tests-empty-orb" aria-hidden="true" />
921 <h3>AI backend unavailable</h3>
922 <p>
923 Couldn't generate a test stub. The AI backend may not be
924 configured, or the model returned an empty response. A suggested
925 path was still computed: <code>{result.suggestedPath}</code>.
926 </p>
927 </div>
928 )}
929
930 <section class="ai-tests-section">
931 <div class="ai-tests-panel">
932 <header class="ai-tests-panel-head">
933 <p class="ai-tests-panel-title">
934 <span class="ai-tests-panel-dot" aria-hidden="true" />
935 Source · <code>{path}</code>
936 </p>
937 </header>
938 <pre class="ai-tests-pre hljs">
939 <code>{raw(sourceHl.html)}</code>
940 </pre>
941 </div>
942 </section>
943
944 <section class="ai-tests-section">
945 <div class="ai-tests-panel">
946 <header class="ai-tests-panel-head">
947 <p class="ai-tests-panel-title">
948 <span class="ai-tests-panel-dot" aria-hidden="true" />
949 Suggested test · <code>{result.suggestedPath}</code>
950 </p>
951 <button
952 type="button"
953 class="ai-tests-copy"
954 id="copy-test-btn"
955 data-ai-tests-copy
956 data-test-code-id="ai-test-code"
957 aria-label="Copy test to clipboard"
958 >
959 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
960 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
961 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
962 </svg>
963 <span data-ai-tests-copy-label>Copy</span>
964 </button>
965 </header>
966 <pre
967 class="ai-tests-pre hljs"
968 id="ai-test-code"
969 data-ai-tests-code
970 >
971 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
972 </pre>
973 </div>
974 </section>
975
976 <div class="ai-tests-poweredby">
977 <span class="ai-tests-poweredby-pill">
978 <span class="dot" aria-hidden="true" />
979 Powered by Claude
980 </span>
409981 </div>
410 <pre
411 class="hljs"
412 id="ai-test-code"
413 style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto; white-space: pre;"
414 >
415 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
416 </pre>
417 </section>
418
419 {html`<script>
420 (function () {
421 var btn = document.getElementById('copy-test-btn');
422 if (!btn) return;
423 btn.addEventListener('click', function () {
424 var id = btn.getAttribute('data-test-code-id');
425 var el = id ? document.getElementById(id) : null;
426 var text = el ? el.innerText : '';
427 if (!text) return;
428 if (navigator.clipboard && navigator.clipboard.writeText) {
429 navigator.clipboard.writeText(text).then(function () {
430 var prev = btn.textContent;
431 btn.textContent = 'Copied';
432 setTimeout(function () { btn.textContent = prev; }, 1500);
433 });
434 }
435 });
436 })();
437 </script>`}
982 </div>
983 <style dangerouslySetInnerHTML={{ __html: styles }} />
984 <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
438985 </Layout>
439986 );
440987 }
441988