Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit93812e4unknown_key

polish: 2026 cards for follows + api-docs + repo specs

Claude committed on May 25, 2026Parent: 3918b2e
5 files changed+266249493812e46d8bbb592db40932d3a48ff729fcd00f3
5 changed files+2662−494
Modifiedsrc/routes/api-docs.tsx+507−74View fileUnifiedSplit
11/**
22 * API Documentation — interactive docs page.
3 *
4 * 2026 polish: scoped `.apidocs-*` class system gives every endpoint group
5 * its own card, every endpoint its own row with a color-coded method pill,
6 * and every code example a solid-white spec block with a Copy button
7 * (recipe lifted from `build-agent-spec.tsx`). Nothing outside `.apidocs-*`
8 * is touched and the route surface is unchanged.
39 */
410
511import { Hono } from "hono";
915
1016const apiDocs = new Hono<AuthEnv>();
1117
18// ─── Scoped CSS (.apidocs-*) ────────────────────────────────────────────────
19const apiDocsStyles = `
20 .apidocs-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
21
22 /* ─── Header ─── */
23 .apidocs-head { margin-bottom: var(--space-5); }
24 .apidocs-eyebrow {
25 display: inline-flex;
26 align-items: center;
27 gap: 8px;
28 text-transform: uppercase;
29 font-family: var(--font-mono);
30 font-size: 11px;
31 letter-spacing: 0.16em;
32 color: var(--text-muted);
33 font-weight: 600;
34 margin-bottom: 10px;
35 }
36 .apidocs-eyebrow-dot {
37 width: 8px; height: 8px;
38 border-radius: 9999px;
39 background: linear-gradient(135deg, #8c6dff, #36c5d6);
40 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
41 }
42 .apidocs-title {
43 font-family: var(--font-display);
44 font-size: clamp(26px, 3.6vw, 40px);
45 font-weight: 800;
46 letter-spacing: -0.028em;
47 line-height: 1.05;
48 margin: 0 0 6px;
49 color: var(--text-strong);
50 }
51 .apidocs-title-grad {
52 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
53 -webkit-background-clip: text;
54 background-clip: text;
55 -webkit-text-fill-color: transparent;
56 color: transparent;
57 }
58 .apidocs-sub {
59 margin: 0;
60 font-size: 14.5px;
61 color: var(--text-muted);
62 line-height: 1.5;
63 max-width: 720px;
64 }
65 .apidocs-sub a { color: var(--accent); text-decoration: none; }
66 .apidocs-sub a:hover { text-decoration: underline; }
67
68 /* ─── Section card per endpoint group ─── */
69 .apidocs-section {
70 margin-bottom: var(--space-5);
71 background: var(--bg-elevated);
72 border: 1px solid var(--border);
73 border-radius: 14px;
74 overflow: hidden;
75 position: relative;
76 }
77 .apidocs-section::before {
78 content: '';
79 position: absolute;
80 top: 0; left: 0; right: 0;
81 height: 2px;
82 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
83 opacity: 0.55;
84 pointer-events: none;
85 }
86 .apidocs-section-head {
87 padding: var(--space-4) var(--space-5) var(--space-3);
88 border-bottom: 1px solid var(--border);
89 }
90 .apidocs-section-title {
91 margin: 0;
92 font-family: var(--font-display);
93 font-size: 17px;
94 font-weight: 700;
95 letter-spacing: -0.018em;
96 color: var(--text-strong);
97 }
98 .apidocs-section-sub {
99 margin: 6px 0 0;
100 font-size: 12.5px;
101 color: var(--text-muted);
102 line-height: 1.45;
103 }
104 .apidocs-section-sub code,
105 .apidocs-sub code {
106 font-family: var(--font-mono);
107 font-size: 12px;
108 background: rgba(255,255,255,0.04);
109 padding: 1px 6px;
110 border-radius: 4px;
111 }
112 .apidocs-section-body {
113 padding: var(--space-3) var(--space-5) var(--space-4);
114 display: flex;
115 flex-direction: column;
116 gap: 10px;
117 }
118
119 /* ─── Per-endpoint card ─── */
120 .apidocs-endpoint {
121 display: grid;
122 grid-template-columns: 64px 1fr auto;
123 column-gap: 14px;
124 row-gap: 6px;
125 align-items: start;
126 padding: 12px 14px;
127 background: rgba(255,255,255,0.018);
128 border: 1px solid var(--border);
129 border-radius: 11px;
130 transition: border-color 120ms ease, background 120ms ease;
131 }
132 .apidocs-endpoint:hover {
133 border-color: var(--border-strong);
134 background: rgba(255,255,255,0.03);
135 }
136 @media (max-width: 720px) {
137 .apidocs-endpoint {
138 grid-template-columns: 1fr;
139 grid-template-areas: "method" "path" "desc" "badges";
140 }
141 .apidocs-method { grid-area: method; justify-self: start; }
142 .apidocs-path { grid-area: path; }
143 .apidocs-desc { grid-area: desc; }
144 .apidocs-badges { grid-area: badges; justify-self: start; }
145 }
146 .apidocs-method {
147 display: inline-flex;
148 align-items: center;
149 justify-content: center;
150 padding: 3px 8px;
151 min-width: 56px;
152 font-family: var(--font-mono);
153 font-size: 11px;
154 font-weight: 700;
155 letter-spacing: 0.04em;
156 border-radius: 6px;
157 text-align: center;
158 text-transform: uppercase;
159 line-height: 1.4;
160 }
161 .apidocs-method.m-get {
162 background: rgba(52,211,153,0.14);
163 color: #6ee7b7;
164 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
165 }
166 .apidocs-method.m-post {
167 background: rgba(140,109,255,0.16);
168 color: #c4b5fd;
169 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
170 }
171 .apidocs-method.m-put,
172 .apidocs-method.m-patch {
173 background: rgba(251,191,36,0.14);
174 color: #fde68a;
175 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
176 }
177 .apidocs-method.m-delete {
178 background: rgba(248,113,113,0.14);
179 color: #fecaca;
180 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
181 }
182 .apidocs-path {
183 font-family: var(--font-mono);
184 font-size: 13px;
185 color: #e9e2ff;
186 word-break: break-all;
187 line-height: 1.5;
188 }
189 .apidocs-desc {
190 grid-column: 2 / 3;
191 font-size: 12.5px;
192 color: var(--text-muted);
193 line-height: 1.5;
194 }
195 .apidocs-desc code {
196 font-family: var(--font-mono);
197 font-size: 11.5px;
198 background: rgba(255,255,255,0.04);
199 padding: 1px 5px;
200 border-radius: 4px;
201 }
202 .apidocs-params {
203 display: block;
204 margin-top: 4px;
205 font-size: 11.5px;
206 color: var(--text-muted);
207 }
208 .apidocs-badges {
209 display: inline-flex;
210 flex-direction: column;
211 gap: 4px;
212 align-items: flex-end;
213 }
214 @media (max-width: 720px) {
215 .apidocs-badges { flex-direction: row; flex-wrap: wrap; }
216 }
217 .apidocs-badge {
218 display: inline-flex;
219 align-items: center;
220 gap: 4px;
221 padding: 2px 8px;
222 border-radius: 9999px;
223 font-size: 10.5px;
224 font-weight: 600;
225 letter-spacing: 0.04em;
226 text-transform: uppercase;
227 line-height: 1.4;
228 }
229 .apidocs-badge.is-auth {
230 background: rgba(54,197,214,0.14);
231 color: #67e8f9;
232 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32);
233 }
234 .apidocs-badge.is-scope {
235 background: rgba(52,211,153,0.14);
236 color: #6ee7b7;
237 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
238 }
239 .apidocs-badge .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
240
241 /* ─── Rate-limit table ─── */
242 .apidocs-table-wrap {
243 margin: 0;
244 border: 1px solid var(--border);
245 border-radius: 11px;
246 overflow: hidden;
247 }
248 .apidocs-table {
249 width: 100%;
250 border-collapse: collapse;
251 font-size: 13px;
252 }
253 .apidocs-table th {
254 text-align: left;
255 padding: 10px 14px;
256 font-size: 11px;
257 font-weight: 600;
258 text-transform: uppercase;
259 letter-spacing: 0.06em;
260 color: var(--text-muted);
261 background: rgba(255,255,255,0.025);
262 border-bottom: 1px solid var(--border);
263 }
264 .apidocs-table td {
265 padding: 10px 14px;
266 color: var(--text);
267 border-bottom: 1px solid var(--border);
268 font-variant-numeric: tabular-nums;
269 }
270 .apidocs-table tr:last-child td { border-bottom: none; }
271
272 /* ─── Solid-white spec block for examples (build-agent-spec recipe) ─── */
273 .apidocs-spec {
274 background: #ffffff;
275 color: #0a0a0a;
276 border: 1px solid #e5e7eb;
277 border-radius: 12px;
278 overflow: hidden;
279 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 6px 20px rgba(0,0,0,0.16);
280 }
281 .apidocs-spec-head {
282 display: flex;
283 align-items: center;
284 justify-content: space-between;
285 gap: 12px;
286 padding: 10px 14px;
287 background: #f9fafb;
288 border-bottom: 1px solid #e5e7eb;
289 flex-wrap: wrap;
290 }
291 .apidocs-spec-title {
292 display: flex;
293 align-items: center;
294 gap: 9px;
295 margin: 0;
296 font-family: var(--font-display, system-ui, sans-serif);
297 font-size: 13px;
298 font-weight: 700;
299 color: #111827;
300 letter-spacing: -0.005em;
301 }
302 .apidocs-spec-dot {
303 width: 7px; height: 7px;
304 border-radius: 9999px;
305 background: linear-gradient(135deg, #8c6dff, #36c5d6);
306 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
307 }
308 .apidocs-spec-copy {
309 display: inline-flex;
310 align-items: center;
311 gap: 6px;
312 padding: 5px 11px;
313 font-size: 12px;
314 font-weight: 600;
315 color: #111827;
316 background: #ffffff;
317 border: 1px solid #d1d5db;
318 border-radius: 7px;
319 cursor: pointer;
320 font-family: inherit;
321 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
322 }
323 .apidocs-spec-copy:hover {
324 background: #f3f4f6;
325 border-color: #9ca3af;
326 }
327 .apidocs-spec-copy.is-copied {
328 background: #ecfdf5;
329 border-color: #6ee7b7;
330 color: #047857;
331 }
332 .apidocs-spec-copy svg { display: block; }
333 .apidocs-spec-pre {
334 margin: 0;
335 padding: 16px 18px;
336 font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
337 font-size: 12.5px;
338 line-height: 1.65;
339 color: #0a0a0a;
340 background: #ffffff;
341 white-space: pre;
342 overflow-x: auto;
343 tab-size: 2;
344 }
345
346 /* ─── Footer / kbd ─── */
347 .apidocs-foot {
348 margin-top: var(--space-5);
349 padding: var(--space-4);
350 text-align: center;
351 color: var(--text-muted);
352 font-size: 13px;
353 border: 1px dashed var(--border);
354 border-radius: 12px;
355 }
356 .apidocs-foot code {
357 font-family: var(--font-mono);
358 font-size: 12px;
359 background: rgba(255,255,255,0.04);
360 padding: 1px 6px;
361 border-radius: 4px;
362 color: var(--text-strong);
363 }
364 .apidocs-kbd {
365 display: inline-block;
366 padding: 2px 6px;
367 font-family: var(--font-mono);
368 font-size: 11px;
369 color: var(--text-strong);
370 background: rgba(255,255,255,0.04);
371 border: 1px solid var(--border-strong);
372 border-bottom-width: 2px;
373 border-radius: 4px;
374 }
375`;
376
12377apiDocs.get("/api/docs", softAuth, (c) => {
13378 const user = c.get("user");
14379
15380 return c.html(
16381 <Layout title="API Documentation" user={user}>
17 <div style="max-width:900px">
18 <h1 style="margin-bottom:8px">gluecron API</h1>
19 <p style="color:var(--text-muted);margin-bottom:32px">
20 Complete REST API for programmatic access to repositories, issues, pull requests, and more.
21 </p>
382 <div class="apidocs-wrap">
383 <header class="apidocs-head">
384 <div class="apidocs-eyebrow">
385 <span class="apidocs-eyebrow-dot" aria-hidden="true" />
386 REST API · v2 · Public reference
387 </div>
388 <h1 class="apidocs-title">
389 <span class="apidocs-title-grad">gluecron API.</span>
390 </h1>
391 <p class="apidocs-sub">
392 Complete REST surface for programmatic access to repositories,
393 issues, pull requests, and more. Shapes match GitHub REST v3 — a
394 base-URL swap reuses most existing integrations. Build a token at{" "}
395 <a href="/settings/tokens">/settings/tokens</a>.
396 </p>
397 </header>
22398
23399 <ApiSection
24400 title="Authentication"
25401 description="All API requests require authentication via a personal access token."
26402 >
27403 <CodeExample
404 id="auth"
28405 title="Using a Bearer token"
29406 code={`curl -H "Authorization: Bearer glue_your_token_here" \\
30407 https://gluecron.com/api/v2/user`}
31408 />
32 <p style="font-size:14px;color:var(--text-muted);margin-top:12px">
33 Create a token at <a href="/settings/tokens">/settings/tokens</a>. Tokens support scopes: <code>repo</code>, <code>user</code>, <code>admin</code>.
409 <p class="apidocs-section-sub">
410 Create a token at <a href="/settings/tokens" style="color:var(--accent);text-decoration:none">/settings/tokens</a>. Tokens support scopes: <code>repo</code>, <code>user</code>, <code>admin</code>.
34411 </p>
35412 </ApiSection>
36413
44421 ]}
45422 headers={["Scope", "Limit"]}
46423 />
47 <p style="font-size:13px;color:var(--text-muted);margin-top:8px">
48 Rate limit info is included in response headers: <code>X-RateLimit-Limit</code>, <code>X-RateLimit-Remaining</code>, <code>X-RateLimit-Reset</code>.
424 <p class="apidocs-section-sub" style="margin-top:10px">
425 Rate-limit info is included in response headers: <code>X-RateLimit-Limit</code>, <code>X-RateLimit-Remaining</code>, <code>X-RateLimit-Reset</code>.
49426 </p>
50427 </ApiSection>
51428
124501 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/statuses/:sha" description="Create status check" auth scope="repo" />
125502 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/statuses/:sha" description="Get status checks for commit" />
126503 <CodeExample
504 id="status-ci"
127505 title="Report CI status"
128506 code={`curl -X POST -H "Authorization: Bearer glue_xxx" \\
129507 -H "Content-Type: application/json" \\
132510 />
133511 </ApiSection>
134512
135 <div style="text-align:center;padding:40px 0;color:var(--text-muted)">
136 <p>API index: <code>GET /api/v2</code> returns machine-readable endpoint listing</p>
137 <p style="margin-top:8px;font-size:13px">Press <kbd class="kbd">?</kbd> for keyboard shortcuts</p>
513 <div class="apidocs-foot">
514 API index: <code>GET /api/v2</code> returns a machine-readable endpoint listing.
515 Press <span class="apidocs-kbd">?</span> for keyboard shortcuts.
138516 </div>
139517 </div>
518 <style dangerouslySetInnerHTML={{ __html: apiDocsStyles }} />
519 <script
520 dangerouslySetInnerHTML={{
521 __html: `
522 (function(){
523 var btns = document.querySelectorAll('[data-apidocs-copy]');
524 btns.forEach(function(btn){
525 var pre = btn.parentNode && btn.parentNode.parentNode
526 ? btn.parentNode.parentNode.querySelector('[data-apidocs-pre]')
527 : null;
528 var label = btn.querySelector('[data-apidocs-copy-label]');
529 if (!pre || !label) return;
530 btn.addEventListener('click', function(){
531 var text = pre.textContent || '';
532 var done = function(){
533 btn.classList.add('is-copied');
534 label.textContent = 'Copied';
535 setTimeout(function(){
536 btn.classList.remove('is-copied');
537 label.textContent = 'Copy';
538 }, 1800);
539 };
540 if (navigator.clipboard && navigator.clipboard.writeText) {
541 navigator.clipboard.writeText(text).then(done).catch(function(){
542 var ta = document.createElement('textarea');
543 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
544 document.body.appendChild(ta); ta.select();
545 try { document.execCommand('copy'); done(); } catch(e){}
546 document.body.removeChild(ta);
547 });
548 } else {
549 var ta = document.createElement('textarea');
550 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
551 document.body.appendChild(ta); ta.select();
552 try { document.execCommand('copy'); done(); } catch(e){}
553 document.body.removeChild(ta);
554 }
555 });
556 });
557 })();
558 `,
559 }}
560 />
140561 </Layout>
141562 );
142563});
152573 description?: string;
153574 children: any;
154575}) => (
155 <div style="margin-bottom:32px">
156 <h2 style="font-size:20px;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border)">
157 {title}
158 </h2>
159 {description && (
160 <p style="color:var(--text-muted);font-size:14px;margin-bottom:16px">{description}</p>
161 )}
162 {children}
163 </div>
576 <section class="apidocs-section">
577 <header class="apidocs-section-head">
578 <h2 class="apidocs-section-title">{title}</h2>
579 {description && (
580 <p class="apidocs-section-sub">{description}</p>
581 )}
582 </header>
583 <div class="apidocs-section-body">{children}</div>
584 </section>
164585);
165586
166587const Endpoint = ({
178599 auth?: boolean;
179600 scope?: string;
180601}) => {
181 const methodColor =
182 method === "GET" ? "var(--green)" :
183 method === "POST" ? "var(--accent)" :
184 method === "PUT" || method === "PATCH" ? "var(--yellow)" :
185 method === "DELETE" ? "var(--red)" : "var(--text)";
602 const methodClass =
603 method === "GET" ? "m-get" :
604 method === "POST" ? "m-post" :
605 method === "PUT" ? "m-put" :
606 method === "PATCH" ? "m-patch" :
607 method === "DELETE" ? "m-delete" : "m-get";
186608
187609 return (
188 <div style="padding:10px 0;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap">
189 <span style={`font-family:var(--font-mono);font-size:12px;font-weight:700;color:${methodColor};min-width:60px`}>
190 {method}
191 </span>
192 <code style="font-size:13px;color:var(--text-link);flex:1;min-width:200px">{path}</code>
193 <span style="font-size:13px;color:var(--text-muted);flex:2;min-width:200px">
194 {description}
195 {params && (
196 <span style="display:block;font-size:12px;margin-top:2px">
197 Params: <code>{params}</code>
198 </span>
199 )}
200 </span>
201 <span style="display:flex;gap:4px;flex-shrink:0">
610 <div class="apidocs-endpoint">
611 <span class={`apidocs-method ${methodClass}`}>{method}</span>
612 <code class="apidocs-path">{path}</code>
613 <span class="apidocs-badges">
202614 {auth && (
203 <span style="font-size:11px;padding:2px 6px;border-radius:3px;background:rgba(31,111,235,0.15);color:var(--accent)">
204 AUTH
615 <span class="apidocs-badge is-auth">
616 <span class="dot" aria-hidden="true" />
617 Auth
205618 </span>
206619 )}
207620 {scope && (
208 <span style="font-size:11px;padding:2px 6px;border-radius:3px;background:rgba(63,185,80,0.15);color:var(--green)">
621 <span class="apidocs-badge is-scope">
622 <span class="dot" aria-hidden="true" />
209623 {scope}
210624 </span>
211625 )}
212626 </span>
627 <span class="apidocs-desc">
628 {description}
629 {params && (
630 <span class="apidocs-params">
631 Params: <code>{params}</code>
632 </span>
633 )}
634 </span>
213635 </div>
214636 );
215637};
216638
217const CodeExample = ({ title, code }: { title: string; code: string }) => (
218 <div style="margin:12px 0">
219 {title && (
220 <div style="font-size:13px;color:var(--text-muted);margin-bottom:4px">{title}</div>
221 )}
222 <div style="position:relative">
223 <pre style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;font-family:var(--font-mono);font-size:13px;overflow-x:auto;line-height:1.6">
224 {code}
225 </pre>
639const CodeExample = ({
640 id,
641 title,
642 code,
643}: {
644 id?: string;
645 title: string;
646 code: string;
647}) => (
648 <div class="apidocs-spec" aria-labelledby={id ? `apidocs-spec-${id}` : undefined}>
649 <header class="apidocs-spec-head">
650 <p class="apidocs-spec-title" id={id ? `apidocs-spec-${id}` : undefined}>
651 <span class="apidocs-spec-dot" aria-hidden="true" />
652 {title}
653 </p>
226654 <button
227655 type="button"
228 class="btn btn-sm"
229 data-clipboard={code}
230 style="position:absolute;top:8px;right:8px;font-size:11px"
656 class="apidocs-spec-copy"
657 data-apidocs-copy
658 aria-label={`Copy ${title} example to clipboard`}
231659 >
232 Copy
660 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
661 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
662 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
663 </svg>
664 <span data-apidocs-copy-label>Copy</span>
233665 </button>
234 </div>
666 </header>
667 <pre class="apidocs-spec-pre" data-apidocs-pre>{code}</pre>
235668 </div>
236669);
237670
242675 headers: string[];
243676 rows: string[][];
244677}) => (
245 <table class="file-table" style="margin:8px 0">
246 <thead>
247 <tr>
248 {headers.map((h) => (
249 <th style="padding:8px 16px;text-align:left;font-size:13px;color:var(--text-muted);border-bottom:1px solid var(--border)">
250 {h}
251 </th>
252 ))}
253 </tr>
254 </thead>
255 <tbody>
256 {rows.map((row) => (
678 <div class="apidocs-table-wrap">
679 <table class="apidocs-table">
680 <thead>
257681 <tr>
258 {row.map((cell) => (
259 <td style="padding:8px 16px;font-size:14px">{cell}</td>
682 {headers.map((h) => (
683 <th>{h}</th>
260684 ))}
261685 </tr>
262 ))}
263 </tbody>
264 </table>
686 </thead>
687 <tbody>
688 {rows.map((row) => (
689 <tr>
690 {row.map((cell) => (
691 <td>{cell}</td>
692 ))}
693 </tr>
694 ))}
695 </tbody>
696 </table>
697 </div>
265698);
266699
267700export default apiDocs;
Modifiedsrc/routes/marketing.tsx+249−47View fileUnifiedSplit
3232const PricingPage: FC = () => (
3333 <>
3434 <style dangerouslySetInnerHTML={{ __html: pricingCss }} />
35 <div class="mkt-root">
35 <div class="mkt-page-pricing mkt-root">
3636 <header class="mkt-hero">
37 <div class="eyebrow">Pricing</div>
38 <h1 class="display mkt-hero-title">
39 Honest pricing for{" "}
40 <span class="gradient-text">teams that ship.</span>
41 </h1>
42 <p class="mkt-hero-sub">
43 Self-hosting is free forever. Hosted plans price the AI calls, not
44 the seats. No one pays per developer.
45 </p>
37 <div class="mkt-hero-orb" aria-hidden="true" />
38 <div class="mkt-hero-inner">
39 <div class="mkt-eyebrow">
40 <span class="mkt-eyebrow-pill" aria-hidden="true">
41 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
42 <line x1="12" y1="1" x2="12" y2="23" />
43 <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
44 </svg>
45 </span>
46 Pricing
47 </div>
48 <h1 class="display mkt-hero-title">
49 Honest pricing for{" "}
50 <span class="mkt-grad">teams that ship.</span>
51 </h1>
52 <p class="mkt-hero-sub">
53 Self-hosting is free forever. Hosted plans price the AI calls, not
54 the seats. No one pays per developer.
55 </p>
56 </div>
4657 </header>
4758
4859 <section class="mkt-pricing-grid stagger">
254265const FeaturesPage: FC = () => (
255266 <>
256267 <style dangerouslySetInnerHTML={{ __html: featuresCss }} />
257 <div class="mkt-root">
268 <div class="mkt-page-features mkt-root">
258269 <header class="mkt-hero">
259 <div class="eyebrow">Features</div>
260 <h1 class="display mkt-hero-title">
261 A complete dev platform.{" "}
262 <span class="gradient-text">Nothing extra to buy.</span>
263 </h1>
264 <p class="mkt-hero-sub">
265 Hosting, CI, AI review, security scanning, deploy webhooks,
266 marketplace, packages, pages — every surface you need, ready on
267 day one.
268 </p>
270 <div class="mkt-hero-orb" aria-hidden="true" />
271 <div class="mkt-hero-inner">
272 <div class="mkt-eyebrow">
273 <span class="mkt-eyebrow-pill" aria-hidden="true">
274 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
275 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
276 </svg>
277 </span>
278 Features
279 </div>
280 <h1 class="display mkt-hero-title">
281 A complete dev platform.{" "}
282 <span class="mkt-grad">Nothing extra to buy.</span>
283 </h1>
284 <p class="mkt-hero-sub">
285 Hosting, CI, AI review, security scanning, deploy webhooks,
286 marketplace, packages, pages — every surface you need, ready on
287 day one.
288 </p>
289 </div>
269290 </header>
270291
292 {/* 3-column feature grid teaser — at-a-glance pitch above the deep dives. */}
293 <section class="mkt-section mkt-feature-teaser">
294 <div class="mkt-teaser-grid">
295 <div class="mkt-teaser-card">
296 <div class="mkt-teaser-icon" aria-hidden="true">
297 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
298 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
299 </svg>
300 </div>
301 <h3 class="mkt-teaser-title">AI is a teammate</h3>
302 <p class="mkt-teaser-desc">
303 Claude reviews every PR, drafts changelogs, opens incident
304 issues, fixes failing gates — labelled and revertable.
305 </p>
306 </div>
307 <div class="mkt-teaser-card">
308 <div class="mkt-teaser-icon" aria-hidden="true">
309 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
310 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
311 </svg>
312 </div>
313 <h3 class="mkt-teaser-title">Green by default</h3>
314 <p class="mkt-teaser-desc">
315 Branch protection, secret scanning, required checks, merge
316 queue — pre-wired on every new repo. Nothing broken ships.
317 </p>
318 </div>
319 <div class="mkt-teaser-card">
320 <div class="mkt-teaser-icon" aria-hidden="true">
321 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
322 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
323 </svg>
324 </div>
325 <h3 class="mkt-teaser-title">Real-time everything</h3>
326 <p class="mkt-teaser-desc">
327 SSE for logs, comments, deploys, presence. No polling, no
328 spinners on tabs you've already loaded.
329 </p>
330 </div>
331 </div>
332 </section>
333
271334 <FeatureCategory
272335 eyebrow="Code intelligence"
273336 title="The AI is a teammate, not an upsell."
504567const AboutPage: FC = () => (
505568 <>
506569 <style dangerouslySetInnerHTML={{ __html: aboutCss }} />
507 <div class="mkt-root">
570 <div class="mkt-page-about mkt-root">
508571 <header class="mkt-hero">
509 <div class="eyebrow">About</div>
510 <h1 class="display mkt-hero-title">
511 We're building the platform{" "}
512 <span class="gradient-text">software writes itself on.</span>
513 </h1>
514 <p class="mkt-hero-sub">
515 Most code in 2026 is written by AI. Most reviews are too. The
516 platforms hosting that code were built for a previous era.
517 Gluecron is built for this one.
518 </p>
572 <div class="mkt-hero-orb" aria-hidden="true" />
573 <div class="mkt-hero-inner">
574 <div class="mkt-eyebrow">
575 <span class="mkt-eyebrow-pill" aria-hidden="true">
576 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
577 <circle cx="12" cy="12" r="10" />
578 <line x1="12" y1="16" x2="12" y2="12" />
579 <line x1="12" y1="8" x2="12.01" y2="8" />
580 </svg>
581 </span>
582 About
583 </div>
584 <h1 class="display mkt-hero-title">
585 We're building the platform{" "}
586 <span class="mkt-grad">software writes itself on.</span>
587 </h1>
588 <p class="mkt-hero-sub">
589 Most code in 2026 is written by AI. Most reviews are too. The
590 platforms hosting that code were built for a previous era.
591 Gluecron is built for this one.
592 </p>
593 </div>
519594 </header>
520595
521596 <section class="mkt-section">
691766 padding: 0 16px;
692767 }
693768
694 /* Hero */
695 .mkt-hero {
696 text-align: center;
697 padding: var(--s-16) 0 var(--s-12);
698 max-width: 920px;
699 margin: 0 auto;
769 /* ───── 2026 hero polish ─────
770 Three pages share the same shell (.mkt-page-pricing/-features/-about);
771 each adds its own page-scoped class to qualify selectors so this
772 block can't leak. */
773 .mkt-page-pricing .mkt-hero,
774 .mkt-page-features .mkt-hero,
775 .mkt-page-about .mkt-hero {
700776 position: relative;
777 text-align: center;
778 margin: var(--s-10) auto var(--s-12);
779 max-width: 980px;
780 padding: clamp(28px, 4vw, 56px) clamp(24px, 4vw, 48px);
781 background: var(--bg-elevated);
782 border: 1px solid var(--border);
783 border-radius: 22px;
784 overflow: hidden;
785 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 22px 56px -20px rgba(0,0,0,0.45);
701786 }
702 .mkt-hero::before {
787 .mkt-page-pricing .mkt-hero::before,
788 .mkt-page-features .mkt-hero::before,
789 .mkt-page-about .mkt-hero::before {
703790 content: '';
704791 position: absolute;
705 top: 0; left: 50%;
706 transform: translateX(-50%);
707 width: 70%; height: 60%;
708 background: radial-gradient(ellipse at center, rgba(140,109,255,0.14), transparent 65%);
709 z-index: -1;
792 top: 0; left: 0; right: 0;
793 height: 2px;
794 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
795 opacity: 0.78;
710796 pointer-events: none;
797 z-index: 2;
798 }
799 .mkt-page-pricing .mkt-hero-orb,
800 .mkt-page-features .mkt-hero-orb,
801 .mkt-page-about .mkt-hero-orb {
802 position: absolute;
803 inset: -28% -10% auto auto;
804 width: 520px; height: 520px;
805 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
806 filter: blur(80px);
807 opacity: 0.75;
808 pointer-events: none;
809 z-index: 0;
810 }
811 .mkt-page-pricing .mkt-hero-inner,
812 .mkt-page-features .mkt-hero-inner,
813 .mkt-page-about .mkt-hero-inner { position: relative; z-index: 1; }
814
815 .mkt-page-pricing .mkt-eyebrow,
816 .mkt-page-features .mkt-eyebrow,
817 .mkt-page-about .mkt-eyebrow {
818 display: inline-flex;
819 align-items: center;
820 gap: 8px;
821 font-family: var(--font-mono);
822 font-size: 11.5px;
823 text-transform: uppercase;
824 letter-spacing: 0.14em;
825 color: var(--text-muted);
826 font-weight: 600;
827 margin-bottom: 14px;
828 }
829 .mkt-page-pricing .mkt-eyebrow-pill,
830 .mkt-page-features .mkt-eyebrow-pill,
831 .mkt-page-about .mkt-eyebrow-pill {
832 display: inline-flex;
833 align-items: center;
834 justify-content: center;
835 width: 18px; height: 18px;
836 border-radius: 6px;
837 background: rgba(140,109,255,0.14);
838 color: #b69dff;
839 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
840 }
841 .mkt-page-pricing .mkt-grad,
842 .mkt-page-features .mkt-grad,
843 .mkt-page-about .mkt-grad {
844 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
845 -webkit-background-clip: text;
846 background-clip: text;
847 -webkit-text-fill-color: transparent;
848 color: transparent;
711849 }
712 .mkt-hero .eyebrow { justify-content: center; margin: 0 auto var(--s-4); }
713 .mkt-hero-title {
850 .mkt-page-pricing .mkt-hero-title,
851 .mkt-page-features .mkt-hero-title,
852 .mkt-page-about .mkt-hero-title {
853 font-family: var(--font-display);
714854 font-size: clamp(36px, 6.5vw, 76px);
715855 line-height: 1.02;
716856 letter-spacing: -0.038em;
857 font-weight: 800;
717858 margin: 0 0 var(--s-5);
859 color: var(--text-strong);
718860 }
719 .mkt-hero-sub {
861 .mkt-page-pricing .mkt-hero-sub,
862 .mkt-page-features .mkt-hero-sub,
863 .mkt-page-about .mkt-hero-sub {
720864 font-size: clamp(15px, 1.5vw, 18px);
721865 color: var(--text-muted);
722866 max-width: 640px;
9651109`;
9661110
9671111const featuresCss = sharedMktCss + `
1112 /* 3-col feature teaser — sits between the hero and the deep category grids */
1113 .mkt-page-features .mkt-feature-teaser { margin: var(--s-8) auto var(--s-12); }
1114 .mkt-page-features .mkt-teaser-grid {
1115 display: grid;
1116 grid-template-columns: repeat(3, 1fr);
1117 gap: 14px;
1118 }
1119 .mkt-page-features .mkt-teaser-card {
1120 position: relative;
1121 padding: var(--s-7) var(--s-6);
1122 background: var(--bg-elevated);
1123 border: 1px solid var(--border);
1124 border-radius: 16px;
1125 transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
1126 overflow: hidden;
1127 }
1128 .mkt-page-features .mkt-teaser-card::before {
1129 content: '';
1130 position: absolute;
1131 top: 0; left: 0; right: 0;
1132 height: 1px;
1133 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.40) 30%, rgba(54,197,214,0.40) 70%, transparent 100%);
1134 opacity: 0.7;
1135 }
1136 .mkt-page-features .mkt-teaser-card:hover {
1137 border-color: rgba(140,109,255,0.40);
1138 transform: translateY(-3px);
1139 box-shadow: 0 14px 36px -18px rgba(140,109,255,0.30);
1140 }
1141 .mkt-page-features .mkt-teaser-icon {
1142 display: inline-flex;
1143 align-items: center;
1144 justify-content: center;
1145 width: 40px; height: 40px;
1146 border-radius: 12px;
1147 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.10));
1148 color: #b69dff;
1149 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1150 margin-bottom: var(--s-4);
1151 }
1152 .mkt-page-features .mkt-teaser-title {
1153 font-family: var(--font-display);
1154 font-size: 18px;
1155 font-weight: 700;
1156 letter-spacing: -0.018em;
1157 color: var(--text-strong);
1158 margin: 0 0 var(--s-2);
1159 }
1160 .mkt-page-features .mkt-teaser-desc {
1161 font-size: var(--t-sm);
1162 color: var(--text-muted);
1163 line-height: 1.55;
1164 margin: 0;
1165 }
1166 @media (max-width: 880px) {
1167 .mkt-page-features .mkt-teaser-grid { grid-template-columns: 1fr; }
1168 }
1169
9681170 .mkt-feat-grid {
9691171 display: grid;
9701172 grid-template-columns: repeat(2, 1fr);
Modifiedsrc/routes/merge-queue.tsx+660−113View fileUnifiedSplit
99 * The "process-next" handler is v1 — it just re-runs gates against the base
1010 * and, if green, merges by updating the base branch ref. A full background
1111 * worker is future work; this keeps the feature usable without a daemon.
12 *
13 * 2026 polish: scoped `.mq-*` class system, gradient hero + section cards
14 * mirror admin-integrations.tsx / admin-ops.tsx. State pills use the same
15 * traffic-light dot pattern as collaborators.tsx. RepoHeader / RepoNav are
16 * left untouched — we only own the content beneath them.
1217 */
1318
1419import { Hono } from "hono";
4045const queue = new Hono<AuthEnv>();
4146queue.use("*", softAuth);
4247
48/* ─────────────────────────────────────────────────────────────────────────
49 * Scoped CSS — every class prefixed `.mq-*`. Gradient hairline hero, card
50 * sections, status pills + tabular-nums timing. Hard-pinned to the
51 * 1100px content width spec.
52 * ───────────────────────────────────────────────────────────────────── */
53const mqStyles = `
54 .mq-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
55
56 /* ─── Hero ─── */
57 .mq-hero {
58 position: relative;
59 margin-bottom: var(--space-5);
60 padding: var(--space-5) var(--space-6);
61 background: var(--bg-elevated);
62 border: 1px solid var(--border);
63 border-radius: 16px;
64 overflow: hidden;
65 }
66 .mq-hero::before {
67 content: '';
68 position: absolute;
69 top: 0; left: 0; right: 0;
70 height: 2px;
71 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
72 opacity: 0.7;
73 pointer-events: none;
74 }
75 .mq-hero-orb {
76 position: absolute;
77 inset: -20% -10% auto auto;
78 width: 380px; height: 380px;
79 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
80 filter: blur(80px);
81 opacity: 0.7;
82 pointer-events: none;
83 z-index: 0;
84 }
85 .mq-hero-inner {
86 position: relative;
87 z-index: 1;
88 display: flex;
89 align-items: flex-end;
90 justify-content: space-between;
91 gap: var(--space-4);
92 flex-wrap: wrap;
93 }
94 .mq-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
95 .mq-eyebrow {
96 display: inline-flex;
97 align-items: center;
98 gap: 8px;
99 text-transform: uppercase;
100 font-family: var(--font-mono);
101 font-size: 11px;
102 letter-spacing: 0.16em;
103 color: var(--text-muted);
104 font-weight: 600;
105 margin-bottom: 10px;
106 }
107 .mq-eyebrow-dot {
108 width: 8px; height: 8px;
109 border-radius: 9999px;
110 background: linear-gradient(135deg, #8c6dff, #36c5d6);
111 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
112 }
113 .mq-title {
114 font-family: var(--font-display);
115 font-size: clamp(28px, 4vw, 40px);
116 font-weight: 800;
117 letter-spacing: -0.028em;
118 line-height: 1.05;
119 margin: 0 0 var(--space-2);
120 color: var(--text-strong);
121 }
122 .mq-title-grad {
123 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
124 -webkit-background-clip: text;
125 background-clip: text;
126 -webkit-text-fill-color: transparent;
127 color: transparent;
128 }
129 .mq-sub {
130 font-size: 15px;
131 color: var(--text-muted);
132 margin: 0;
133 line-height: 1.5;
134 max-width: 620px;
135 }
136 .mq-hero-cta {
137 display: inline-flex;
138 align-items: center;
139 gap: 6px;
140 padding: 10px 18px;
141 font-size: 13.5px;
142 font-weight: 600;
143 border-radius: 10px;
144 border: 1px solid transparent;
145 cursor: pointer;
146 font: inherit;
147 line-height: 1;
148 white-space: nowrap;
149 text-decoration: none;
150 color: #ffffff;
151 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
152 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
153 transition: transform 120ms ease, box-shadow 120ms ease;
154 }
155 .mq-hero-cta:hover {
156 transform: translateY(-1px);
157 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
158 text-decoration: none;
159 color: #ffffff;
160 }
161
162 /* ─── Banners ─── */
163 .mq-banner {
164 margin-bottom: var(--space-4);
165 padding: 10px 14px;
166 border-radius: 10px;
167 font-size: 13.5px;
168 border: 1px solid var(--border);
169 background: rgba(255,255,255,0.025);
170 color: var(--text);
171 display: flex;
172 align-items: center;
173 gap: 10px;
174 }
175 .mq-banner.is-ok {
176 border-color: rgba(52,211,153,0.40);
177 background: rgba(52,211,153,0.08);
178 color: #bbf7d0;
179 }
180 .mq-banner.is-error {
181 border-color: rgba(248,113,113,0.40);
182 background: rgba(248,113,113,0.08);
183 color: #fecaca;
184 }
185 .mq-banner-dot {
186 width: 8px; height: 8px;
187 border-radius: 9999px;
188 background: currentColor;
189 flex-shrink: 0;
190 }
191
192 /* ─── Base-branch card ─── */
193 .mq-group {
194 margin-bottom: var(--space-5);
195 background: var(--bg-elevated);
196 border: 1px solid var(--border);
197 border-radius: 14px;
198 overflow: hidden;
199 position: relative;
200 }
201 .mq-group::before {
202 content: '';
203 position: absolute;
204 top: 0; left: 0; right: 0;
205 height: 2px;
206 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
207 opacity: 0.45;
208 pointer-events: none;
209 }
210 .mq-group-head {
211 padding: var(--space-4) var(--space-5);
212 border-bottom: 1px solid var(--border);
213 display: flex;
214 align-items: center;
215 justify-content: space-between;
216 gap: var(--space-3);
217 flex-wrap: wrap;
218 }
219 .mq-group-head-text { flex: 1; min-width: 200px; }
220 .mq-group-title {
221 margin: 0;
222 font-family: var(--font-display);
223 font-size: 15.5px;
224 font-weight: 700;
225 letter-spacing: -0.018em;
226 color: var(--text-strong);
227 display: flex;
228 align-items: center;
229 gap: 10px;
230 flex-wrap: wrap;
231 }
232 .mq-group-title-icon {
233 display: inline-flex;
234 align-items: center;
235 justify-content: center;
236 width: 26px; height: 26px;
237 border-radius: 8px;
238 background: rgba(140,109,255,0.12);
239 color: #b69dff;
240 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
241 flex-shrink: 0;
242 }
243 .mq-base-chip {
244 display: inline-flex;
245 align-items: center;
246 gap: 6px;
247 padding: 3px 9px;
248 border-radius: 8px;
249 background: var(--bg-tertiary);
250 border: 1px solid var(--border);
251 font-family: var(--font-mono);
252 font-size: 12px;
253 color: var(--text-strong);
254 }
255 .mq-group-count {
256 font-family: var(--font-mono);
257 font-size: 12px;
258 color: var(--text-muted);
259 font-weight: 500;
260 font-variant-numeric: tabular-nums;
261 }
262
263 /* ─── Entry rows ─── */
264 .mq-list { display: flex; flex-direction: column; }
265 .mq-row {
266 display: flex;
267 align-items: flex-start;
268 gap: 14px;
269 padding: 14px var(--space-5);
270 border-top: 1px solid var(--border);
271 }
272 .mq-row:first-child { border-top: none; }
273 .mq-row-body { flex: 1; min-width: 0; }
274 .mq-row-head {
275 display: flex;
276 align-items: center;
277 gap: 10px;
278 flex-wrap: wrap;
279 }
280 .mq-row-link {
281 font-family: var(--font-display);
282 font-weight: 700;
283 font-size: 14.5px;
284 color: var(--text-strong);
285 text-decoration: none;
286 letter-spacing: -0.005em;
287 }
288 .mq-row-link:hover { text-decoration: underline; }
289 .mq-row-num {
290 font-family: var(--font-mono);
291 font-size: 13px;
292 color: var(--text-muted);
293 font-variant-numeric: tabular-nums;
294 margin-right: 2px;
295 }
296 .mq-row-meta {
297 margin-top: 6px;
298 display: flex;
299 align-items: center;
300 gap: 10px;
301 flex-wrap: wrap;
302 font-size: 12px;
303 color: var(--text-muted);
304 font-variant-numeric: tabular-nums;
305 }
306 .mq-row-meta .sep { opacity: 0.45; }
307 .mq-row-meta code {
308 font-family: var(--font-mono);
309 font-size: 11.5px;
310 color: var(--text);
311 background: var(--bg-tertiary);
312 padding: 1px 5px;
313 border-radius: 4px;
314 }
315 .mq-row-err {
316 margin-top: 8px;
317 padding: 8px 10px;
318 background: rgba(248,113,113,0.06);
319 border: 1px solid rgba(248,113,113,0.28);
320 border-radius: 8px;
321 font-family: var(--font-mono);
322 font-size: 12px;
323 color: #fecaca;
324 line-height: 1.45;
325 }
326 .mq-row-action { flex-shrink: 0; }
327 .mq-row-action form { margin: 0; }
328
329 /* ─── State pills ─── */
330 .mq-pill {
331 display: inline-flex;
332 align-items: center;
333 gap: 6px;
334 padding: 3px 9px;
335 border-radius: 9999px;
336 font-size: 11px;
337 font-weight: 600;
338 letter-spacing: 0.04em;
339 text-transform: uppercase;
340 }
341 .mq-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
342 .mq-pill.is-queued {
343 background: rgba(148,163,184,0.16);
344 color: #cbd5e1;
345 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
346 }
347 .mq-pill.is-running {
348 background: rgba(54,197,214,0.14);
349 color: #67e8f9;
350 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32);
351 }
352 .mq-pill.is-merged {
353 background: rgba(140,109,255,0.16);
354 color: #c4b5fd;
355 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
356 }
357 .mq-pill.is-failed {
358 background: rgba(248,113,113,0.14);
359 color: #fca5a5;
360 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
361 }
362 .mq-pill.is-dequeued {
363 background: rgba(107,114,128,0.18);
364 color: #d1d5db;
365 box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32);
366 }
367
368 /* ─── Buttons ─── */
369 .mq-btn {
370 display: inline-flex;
371 align-items: center;
372 justify-content: center;
373 gap: 6px;
374 padding: 7px 14px;
375 border-radius: 9px;
376 font-size: 12.5px;
377 font-weight: 600;
378 text-decoration: none;
379 border: 1px solid transparent;
380 cursor: pointer;
381 font: inherit;
382 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
383 line-height: 1;
384 white-space: nowrap;
385 }
386 .mq-btn-primary {
387 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
388 color: #ffffff;
389 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
390 }
391 .mq-btn-primary:hover {
392 transform: translateY(-1px);
393 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
394 color: #ffffff;
395 text-decoration: none;
396 }
397 .mq-btn-ghost {
398 background: transparent;
399 color: var(--text);
400 border-color: var(--border-strong);
401 }
402 .mq-btn-ghost:hover {
403 background: rgba(140,109,255,0.06);
404 border-color: rgba(140,109,255,0.45);
405 color: var(--text-strong);
406 text-decoration: none;
407 }
408
409 /* ─── Crumb back link ─── */
410 .mq-crumbs {
411 display: flex;
412 align-items: center;
413 gap: 12px;
414 flex-wrap: wrap;
415 margin-bottom: var(--space-4);
416 font-size: 12.5px;
417 }
418 .mq-crumbs a {
419 display: inline-flex;
420 align-items: center;
421 gap: 5px;
422 padding: 6px 11px;
423 background: rgba(255,255,255,0.025);
424 border: 1px solid var(--border);
425 border-radius: 8px;
426 color: var(--text-muted);
427 text-decoration: none;
428 font-weight: 500;
429 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
430 }
431 .mq-crumbs a:hover {
432 border-color: var(--border-strong);
433 color: var(--text-strong);
434 background: rgba(255,255,255,0.04);
435 text-decoration: none;
436 }
437
438 /* ─── Empty state ─── */
439 .mq-empty {
440 position: relative;
441 overflow: hidden;
442 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
443 text-align: center;
444 background: var(--bg-elevated);
445 border: 1px dashed var(--border-strong);
446 border-radius: 16px;
447 }
448 .mq-empty-orb {
449 position: absolute;
450 inset: -40% 30% auto 30%;
451 height: 280px;
452 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
453 filter: blur(70px);
454 opacity: 0.7;
455 pointer-events: none;
456 z-index: 0;
457 }
458 .mq-empty-inner { position: relative; z-index: 1; }
459 .mq-empty-icon {
460 width: 56px; height: 56px;
461 border-radius: 9999px;
462 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
463 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
464 display: inline-flex;
465 align-items: center;
466 justify-content: center;
467 color: #c4b5fd;
468 margin-bottom: 14px;
469 }
470 .mq-empty-title {
471 font-family: var(--font-display);
472 font-size: 18px;
473 font-weight: 700;
474 margin: 0 0 6px;
475 color: var(--text-strong);
476 }
477 .mq-empty-sub {
478 margin: 0 auto 16px;
479 font-size: 13.5px;
480 color: var(--text-muted);
481 max-width: 460px;
482 line-height: 1.5;
483 }
484`;
485
486function IconQueue() {
487 return (
488 <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">
489 <line x1="8" y1="6" x2="21" y2="6" />
490 <line x1="8" y1="12" x2="21" y2="12" />
491 <line x1="8" y1="18" x2="21" y2="18" />
492 <line x1="3" y1="6" x2="3.01" y2="6" />
493 <line x1="3" y1="12" x2="3.01" y2="12" />
494 <line x1="3" y1="18" x2="3.01" y2="18" />
495 </svg>
496 );
497}
498function IconBranch() {
499 return (
500 <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">
501 <line x1="6" y1="3" x2="6" y2="15" />
502 <circle cx="18" cy="6" r="3" />
503 <circle cx="6" cy="18" r="3" />
504 <path d="M18 9a9 9 0 0 1-9 9" />
505 </svg>
506 );
507}
508function IconArrowLeft() {
509 return (
510 <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">
511 <line x1="19" y1="12" x2="5" y2="12" />
512 <polyline points="12 19 5 12 12 5" />
513 </svg>
514 );
515}
516
43517async function loadRepo(ownerName: string, repoName: string) {
44518 try {
45519 const [row] = await db
74548 return t.toLocaleDateString();
75549}
76550
551function statePill(state: string) {
552 const cls =
553 state === "running"
554 ? "is-running"
555 : state === "merged"
556 ? "is-merged"
557 : state === "failed"
558 ? "is-failed"
559 : state === "dequeued"
560 ? "is-dequeued"
561 : "is-queued";
562 return (
563 <span class={`mq-pill ${cls}`}>
564 <span class="dot" aria-hidden="true" />
565 {state}
566 </span>
567 );
568}
569
77570// ---------- Queue list ----------
78571
79572queue.get("/:owner/:repo/queue", async (c) => {
83576 if (!repoRow) {
84577 return c.html(
85578 <Layout title="Not found" user={user}>
86 <div class="empty-state">
87 <h2>Repository not found</h2>
579 <div class="mq-wrap">
580 <div class="mq-empty">
581 <div class="mq-empty-orb" aria-hidden="true" />
582 <div class="mq-empty-inner">
583 <div class="mq-empty-icon" aria-hidden="true">
584 <IconQueue />
585 </div>
586 <h2 class="mq-empty-title">Repository not found</h2>
587 <p class="mq-empty-sub">
588 Check the owner and repo name in the URL.
589 </p>
590 </div>
591 </div>
88592 </div>
593 <style dangerouslySetInnerHTML={{ __html: mqStyles }} />
89594 </Layout>,
90595 404
91596 );
103608 const success = c.req.query("success");
104609 const error = c.req.query("error");
105610
106 const stateBadge = (s: string) => {
107 const style: Record<string, string> = {
108 queued: "background:#30363d;color:#c9d1d9",
109 running: "background:#1f6feb;color:white",
110 merged: "background:#8957e5;color:white",
111 failed: "background:#da3633;color:white",
112 dequeued: "background:#484f58;color:#c9d1d9",
113 };
114 return (
115 <span
116 style={`${style[s] || style.queued};padding:2px 8px;border-radius:3px;font-size:11px;text-transform:uppercase;font-weight:600`}
117 >
118 {s}
119 </span>
120 );
121 };
122
123611 return c.html(
124612 <Layout title={`Merge queue — ${owner}/${repo}`} user={user}>
125613 <RepoHeader
131619 />
132620 <RepoNav owner={owner} repo={repo} active="pulls" />
133621
134 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
135 <h3>Merge queue</h3>
136 <a href={`/${owner}/${repo}/pulls`} class="btn btn-sm">
137 Back to PRs
138 </a>
139 </div>
622 <div class="mq-wrap">
623 <div class="mq-crumbs">
624 <a href={`/${owner}/${repo}/pulls`}>
625 <IconArrowLeft />
626 Back to pull requests
627 </a>
628 </div>
140629
141 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
142 Serialised merges: PRs queued here re-run gates against the latest base
143 before being merged. This prevents green-in-isolation / red-after-merge
144 races.
145 </p>
630 <section class="mq-hero">
631 <div class="mq-hero-orb" aria-hidden="true" />
632 <div class="mq-hero-inner">
633 <div class="mq-hero-text">
634 <div class="mq-eyebrow">
635 <span class="mq-eyebrow-dot" aria-hidden="true" />
636 Repository · Merge queue
637 </div>
638 <h1 class="mq-title">
639 <span class="mq-title-grad">Serialised merges.</span>
640 </h1>
641 <p class="mq-sub">
642 Queued PRs re-run gates against the latest base before merging.
643 This prevents green-in-isolation, red-after-merge races.
644 </p>
645 </div>
646 <a href={`/${owner}/${repo}/pulls`} class="mq-hero-cta">
647 <IconQueue />
648 Browse PRs
649 </a>
650 </div>
651 </section>
146652
147 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
653 {success && (
654 <div class="mq-banner is-ok" role="status">
655 <span class="mq-banner-dot" aria-hidden="true" />
656 {decodeURIComponent(success)}
657 </div>
658 )}
659 {error && (
660 <div class="mq-banner is-error" role="alert">
661 <span class="mq-banner-dot" aria-hidden="true" />
662 {decodeURIComponent(error)}
663 </div>
664 )}
149665
150 {entries.length === 0 ? (
151 <div class="empty-state">
152 <p>Queue is empty. Enqueue a PR from the pull-request page.</p>
153 </div>
154 ) : (
155 Array.from(byBranch.entries()).map(([branch, items]) => {
156 const active = items.filter(
157 (i) => i.state === "queued" || i.state === "running"
158 );
159 return (
160 <div class="panel" style="margin-bottom:20px;overflow:hidden">
161 <div style="padding:12px 14px;background:var(--bg-tertiary);display:flex;justify-content:space-between;align-items:center">
162 <div style="font-weight:600">
163 Base: <code>{branch}</code>{" "}
164 <span style="font-size:12px;color:var(--text-muted);font-weight:400;margin-left:8px">
165 {active.length} active
166 </span>
167 </div>
168 {isOwner && active.length > 0 && (
169 <form
170 method="post"
171 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
172 >
173 <button type="submit" class="btn btn-sm btn-primary">
174 Process next
175 </button>
176 </form>
177 )}
666 {entries.length === 0 ? (
667 <div class="mq-empty">
668 <div class="mq-empty-orb" aria-hidden="true" />
669 <div class="mq-empty-inner">
670 <div class="mq-empty-icon" aria-hidden="true">
671 <IconQueue />
178672 </div>
179 {items.map((it) => (
180 <div
181 class="panel-item"
182 style="justify-content:space-between;align-items:flex-start"
183 >
184 <div style="flex:1;min-width:0">
185 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
186 {stateBadge(it.state)}
187 {it.prNumber != null ? (
188 <a
189 href={`/${owner}/${repo}/pulls/${it.prNumber}`}
190 style="font-weight:600"
191 >
192 #{it.prNumber} {it.prTitle}
193 </a>
194 ) : (
195 <span style="color:var(--text-muted)">(PR gone)</span>
196 )}
197 </div>
198 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
199 pos {it.position} ·{" "}
200 {it.prHeadBranch ? <code>{it.prHeadBranch}</code> : ""} ·
201 enqueued {relTime(it.enqueuedAt)}
202 {it.startedAt
203 ? ` · started ${relTime(it.startedAt)}`
204 : ""}
205 {it.finishedAt
206 ? ` · finished ${relTime(it.finishedAt)}`
207 : ""}
208 </div>
209 {it.errorMessage && (
210 <div style="font-size:12px;color:var(--red);margin-top:4px">
211 {it.errorMessage}
212 </div>
213 )}
673 <h2 class="mq-empty-title">Queue is empty</h2>
674 <p class="mq-empty-sub">
675 Enqueue an open, non-draft PR from its pull-request page to
676 start a serialised merge.
677 </p>
678 <a href={`/${owner}/${repo}/pulls`} class="mq-btn mq-btn-primary">
679 Pick a PR to enqueue
680 </a>
681 </div>
682 </div>
683 ) : (
684 Array.from(byBranch.entries()).map(([branch, items]) => {
685 const active = items.filter(
686 (i) => i.state === "queued" || i.state === "running"
687 );
688 return (
689 <section class="mq-group">
690 <header class="mq-group-head">
691 <div class="mq-group-head-text">
692 <h2 class="mq-group-title">
693 <span class="mq-group-title-icon" aria-hidden="true">
694 <IconBranch />
695 </span>
696 Base
697 <span class="mq-base-chip">{branch}</span>
698 <span class="mq-group-count">{active.length} active</span>
699 </h2>
214700 </div>
215 {(it.state === "queued" || it.state === "running") &&
216 user &&
217 (isOwner || user.id === it.enqueuedBy) && (
218 <form
219 method="post"
220 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
221 onsubmit="return confirm('Remove from queue?')"
222 >
223 <button type="submit" class="btn btn-sm">
224 Remove
225 </button>
226 </form>
227 )}
701 {isOwner && active.length > 0 && (
702 <form
703 method="post"
704 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
705 >
706 <button type="submit" class="mq-btn mq-btn-primary">
707 Process next
708 </button>
709 </form>
710 )}
711 </header>
712 <div class="mq-list">
713 {items.map((it) => (
714 <div class="mq-row">
715 <div class="mq-row-body">
716 <div class="mq-row-head">
717 {statePill(it.state)}
718 {it.prNumber != null ? (
719 <a
720 href={`/${owner}/${repo}/pulls/${it.prNumber}`}
721 class="mq-row-link"
722 >
723 <span class="mq-row-num">#{it.prNumber}</span>
724 {it.prTitle}
725 </a>
726 ) : (
727 <span style="color:var(--text-muted)">(PR gone)</span>
728 )}
729 </div>
730 <div class="mq-row-meta">
731 <span>pos {it.position}</span>
732 {it.prHeadBranch && (
733 <>
734 <span class="sep">·</span>
735 <code>{it.prHeadBranch}</code>
736 </>
737 )}
738 <span class="sep">·</span>
739 <span>enqueued {relTime(it.enqueuedAt)}</span>
740 {it.startedAt && (
741 <>
742 <span class="sep">·</span>
743 <span>started {relTime(it.startedAt)}</span>
744 </>
745 )}
746 {it.finishedAt && (
747 <>
748 <span class="sep">·</span>
749 <span>finished {relTime(it.finishedAt)}</span>
750 </>
751 )}
752 </div>
753 {it.errorMessage && (
754 <div class="mq-row-err">{it.errorMessage}</div>
755 )}
756 </div>
757 {(it.state === "queued" || it.state === "running") &&
758 user &&
759 (isOwner || user.id === it.enqueuedBy) && (
760 <div class="mq-row-action">
761 <form
762 method="post"
763 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
764 onsubmit="return confirm('Remove from queue?')"
765 >
766 <button type="submit" class="mq-btn mq-btn-ghost">
767 Remove
768 </button>
769 </form>
770 </div>
771 )}
772 </div>
773 ))}
228774 </div>
229 ))}
230 </div>
231 );
232 })
233 )}
775 </section>
776 );
777 })
778 )}
779 </div>
780 <style dangerouslySetInnerHTML={{ __html: mqStyles }} />
234781 </Layout>
235782 );
236783});
Modifiedsrc/routes/mirrors.tsx+773−149View fileUnifiedSplit
66 * POST /:owner/:repo/settings/mirror/delete — remove mirror config
77 * POST /:owner/:repo/settings/mirror/sync — run one sync now (owner-only)
88 * POST /admin/mirrors/sync-all — site admin, run all due mirrors
9 *
10 * 2026 polish: scoped `.mirror-*` class system mirrors the gradient hero +
11 * card patterns from admin-integrations.tsx and admin-ops.tsx. The hero
12 * lives inside the page (below RepoHeader); the recent-run list is
13 * card-per-row with status pill, tabular-nums timing, mono SHA/URL. All
14 * form actions, query params, and POST handlers preserved verbatim.
915 */
1016
1117import { Hono } from "hono";
3339const mirrors = new Hono<AuthEnv>();
3440mirrors.use("*", softAuth);
3541
42/* ─────────────────────────────────────────────────────────────────────────
43 * Scoped CSS — every class prefixed `.mirror-*` so the surface can't bleed
44 * into RepoHeader / nav above. Tokens reused from the layout
45 * (--bg-elevated, --border, --text-strong, --space-*, --font-*).
46 * ───────────────────────────────────────────────────────────────────── */
47const mirrorStyles = `
48 .mirror-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
49
50 /* ─── Hero ─── */
51 .mirror-hero {
52 position: relative;
53 margin-bottom: var(--space-5);
54 padding: var(--space-5) var(--space-6);
55 background: var(--bg-elevated);
56 border: 1px solid var(--border);
57 border-radius: 16px;
58 overflow: hidden;
59 }
60 .mirror-hero::before {
61 content: '';
62 position: absolute;
63 top: 0; left: 0; right: 0;
64 height: 2px;
65 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
66 opacity: 0.7;
67 pointer-events: none;
68 }
69 .mirror-hero-orb {
70 position: absolute;
71 inset: -20% -10% auto auto;
72 width: 380px; height: 380px;
73 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
74 filter: blur(80px);
75 opacity: 0.7;
76 pointer-events: none;
77 z-index: 0;
78 }
79 .mirror-hero-inner {
80 position: relative;
81 z-index: 1;
82 display: flex;
83 align-items: flex-end;
84 justify-content: space-between;
85 gap: var(--space-4);
86 flex-wrap: wrap;
87 }
88 .mirror-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
89 .mirror-eyebrow {
90 display: inline-flex;
91 align-items: center;
92 gap: 8px;
93 text-transform: uppercase;
94 font-family: var(--font-mono);
95 font-size: 11px;
96 letter-spacing: 0.16em;
97 color: var(--text-muted);
98 font-weight: 600;
99 margin-bottom: 10px;
100 }
101 .mirror-eyebrow-dot {
102 width: 8px; height: 8px;
103 border-radius: 9999px;
104 background: linear-gradient(135deg, #8c6dff, #36c5d6);
105 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
106 }
107 .mirror-title {
108 font-family: var(--font-display);
109 font-size: clamp(28px, 4vw, 40px);
110 font-weight: 800;
111 letter-spacing: -0.028em;
112 line-height: 1.05;
113 margin: 0 0 var(--space-2);
114 color: var(--text-strong);
115 }
116 .mirror-title-grad {
117 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
118 -webkit-background-clip: text;
119 background-clip: text;
120 -webkit-text-fill-color: transparent;
121 color: transparent;
122 }
123 .mirror-sub {
124 font-size: 15px;
125 color: var(--text-muted);
126 margin: 0;
127 line-height: 1.5;
128 max-width: 620px;
129 }
130 .mirror-sub code {
131 font-family: var(--font-mono);
132 font-size: 13px;
133 background: var(--bg-tertiary);
134 padding: 1px 5px;
135 border-radius: 4px;
136 }
137 .mirror-hero-cta {
138 display: inline-flex;
139 align-items: center;
140 gap: 6px;
141 padding: 10px 18px;
142 font-size: 13.5px;
143 font-weight: 600;
144 border-radius: 10px;
145 border: 1px solid transparent;
146 cursor: pointer;
147 font: inherit;
148 line-height: 1;
149 white-space: nowrap;
150 text-decoration: none;
151 color: #ffffff;
152 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
153 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
154 transition: transform 120ms ease, box-shadow 120ms ease;
155 }
156 .mirror-hero-cta:hover {
157 transform: translateY(-1px);
158 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
159 text-decoration: none;
160 color: #ffffff;
161 }
162
163 /* ─── Banners ─── */
164 .mirror-banner {
165 margin-bottom: var(--space-4);
166 padding: 10px 14px;
167 border-radius: 10px;
168 font-size: 13.5px;
169 border: 1px solid var(--border);
170 background: rgba(255,255,255,0.025);
171 color: var(--text);
172 display: flex;
173 align-items: center;
174 gap: 10px;
175 }
176 .mirror-banner.is-ok {
177 border-color: rgba(52,211,153,0.40);
178 background: rgba(52,211,153,0.08);
179 color: #bbf7d0;
180 }
181 .mirror-banner.is-error {
182 border-color: rgba(248,113,113,0.40);
183 background: rgba(248,113,113,0.08);
184 color: #fecaca;
185 }
186 .mirror-banner-dot {
187 width: 8px; height: 8px;
188 border-radius: 9999px;
189 background: currentColor;
190 flex-shrink: 0;
191 }
192
193 /* ─── Section cards ─── */
194 .mirror-section {
195 margin-bottom: var(--space-5);
196 background: var(--bg-elevated);
197 border: 1px solid var(--border);
198 border-radius: 14px;
199 overflow: hidden;
200 position: relative;
201 }
202 .mirror-section::before {
203 content: '';
204 position: absolute;
205 top: 0; left: 0; right: 0;
206 height: 2px;
207 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
208 opacity: 0.45;
209 pointer-events: none;
210 }
211 .mirror-section-head {
212 padding: var(--space-4) var(--space-5);
213 border-bottom: 1px solid var(--border);
214 display: flex;
215 align-items: flex-start;
216 justify-content: space-between;
217 gap: var(--space-3);
218 flex-wrap: wrap;
219 }
220 .mirror-section-title {
221 margin: 0;
222 font-family: var(--font-display);
223 font-size: 16px;
224 font-weight: 700;
225 letter-spacing: -0.018em;
226 color: var(--text-strong);
227 display: flex;
228 align-items: center;
229 gap: 10px;
230 }
231 .mirror-section-title-icon {
232 display: inline-flex;
233 align-items: center;
234 justify-content: center;
235 width: 26px; height: 26px;
236 border-radius: 8px;
237 background: rgba(140,109,255,0.12);
238 color: #b69dff;
239 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
240 flex-shrink: 0;
241 }
242 .mirror-section-sub {
243 margin: 6px 0 0 36px;
244 font-size: 12.5px;
245 color: var(--text-muted);
246 line-height: 1.45;
247 }
248 .mirror-section-body { padding: var(--space-4) var(--space-5); }
249
250 /* ─── Form ─── */
251 .mirror-field { margin-bottom: var(--space-4); }
252 .mirror-field:last-child { margin-bottom: 0; }
253 .mirror-field-label {
254 display: block;
255 font-size: 11.5px;
256 color: var(--text-muted);
257 font-weight: 600;
258 text-transform: uppercase;
259 letter-spacing: 0.06em;
260 margin-bottom: 6px;
261 }
262 .mirror-input {
263 width: 100%;
264 box-sizing: border-box;
265 padding: 9px 12px;
266 font: inherit;
267 font-size: 13.5px;
268 color: var(--text);
269 background: rgba(255,255,255,0.03);
270 border: 1px solid var(--border-strong);
271 border-radius: 10px;
272 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
273 font-family: var(--font-mono);
274 }
275 .mirror-input:focus {
276 outline: none;
277 border-color: rgba(140,109,255,0.55);
278 background: rgba(255,255,255,0.05);
279 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
280 }
281 .mirror-input.is-num { max-width: 180px; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
282 .mirror-checkbox-row {
283 display: inline-flex;
284 align-items: center;
285 gap: 9px;
286 padding: 8px 12px;
287 border-radius: 9999px;
288 background: rgba(255,255,255,0.025);
289 border: 1px solid var(--border);
290 font-size: 13px;
291 color: var(--text);
292 cursor: pointer;
293 }
294 .mirror-checkbox-row input { accent-color: #8c6dff; }
295
296 /* ─── Buttons ─── */
297 .mirror-btn {
298 display: inline-flex;
299 align-items: center;
300 justify-content: center;
301 gap: 6px;
302 padding: 9px 16px;
303 border-radius: 10px;
304 font-size: 13px;
305 font-weight: 600;
306 text-decoration: none;
307 border: 1px solid transparent;
308 cursor: pointer;
309 font: inherit;
310 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
311 line-height: 1;
312 white-space: nowrap;
313 }
314 .mirror-btn-primary {
315 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
316 color: #ffffff;
317 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
318 }
319 .mirror-btn-primary:hover {
320 transform: translateY(-1px);
321 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
322 color: #ffffff;
323 text-decoration: none;
324 }
325 .mirror-btn-ghost {
326 background: transparent;
327 color: var(--text);
328 border-color: var(--border-strong);
329 }
330 .mirror-btn-ghost:hover {
331 background: rgba(140,109,255,0.06);
332 border-color: rgba(140,109,255,0.45);
333 color: var(--text-strong);
334 text-decoration: none;
335 }
336 .mirror-btn-danger {
337 background: transparent;
338 color: #fca5a5;
339 border-color: rgba(248,113,113,0.35);
340 }
341 .mirror-btn-danger:hover {
342 border-style: dashed;
343 border-color: rgba(248,113,113,0.70);
344 background: rgba(248,113,113,0.06);
345 color: #fecaca;
346 text-decoration: none;
347 }
348 .mirror-action-row { display: flex; gap: 10px; flex-wrap: wrap; }
349 .mirror-action-row form { margin: 0; }
350
351 /* ─── Status pills ─── */
352 .mirror-pill {
353 display: inline-flex;
354 align-items: center;
355 gap: 6px;
356 padding: 3px 9px;
357 border-radius: 9999px;
358 font-size: 11px;
359 font-weight: 600;
360 letter-spacing: 0.04em;
361 text-transform: uppercase;
362 }
363 .mirror-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
364 .mirror-pill.is-ok {
365 background: rgba(52,211,153,0.14);
366 color: #6ee7b7;
367 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
368 }
369 .mirror-pill.is-error {
370 background: rgba(248,113,113,0.14);
371 color: #fca5a5;
372 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
373 }
374 .mirror-pill.is-pending {
375 background: rgba(251,191,36,0.12);
376 color: #fde68a;
377 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
378 }
379 .mirror-pill.is-neutral {
380 background: rgba(148,163,184,0.16);
381 color: #cbd5e1;
382 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
383 }
384
385 /* ─── Run row cards ─── */
386 .mirror-run-list { display: flex; flex-direction: column; gap: 8px; }
387 .mirror-run-card {
388 display: flex;
389 align-items: center;
390 gap: 14px;
391 padding: 12px 14px;
392 background: rgba(255,255,255,0.018);
393 border: 1px solid var(--border);
394 border-radius: 12px;
395 transition: border-color 120ms ease, background 120ms ease;
396 }
397 .mirror-run-card:hover {
398 border-color: var(--border-strong);
399 background: rgba(255,255,255,0.03);
400 }
401 .mirror-run-meta {
402 flex: 1;
403 min-width: 0;
404 display: flex;
405 align-items: center;
406 gap: 10px;
407 flex-wrap: wrap;
408 font-size: 12.5px;
409 color: var(--text-muted);
410 font-variant-numeric: tabular-nums;
411 }
412 .mirror-run-meta .sep { opacity: 0.45; }
413 .mirror-run-msg {
414 font-size: 12.5px;
415 color: var(--text-muted);
416 max-width: 360px;
417 overflow: hidden;
418 text-overflow: ellipsis;
419 white-space: nowrap;
420 font-family: var(--font-mono);
421 }
422
423 /* ─── Last-run summary ─── */
424 .mirror-last {
425 display: flex;
426 align-items: flex-start;
427 gap: 14px;
428 flex-wrap: wrap;
429 }
430 .mirror-last-left { flex: 1; min-width: 220px; }
431 .mirror-last-stamp {
432 font-family: var(--font-mono);
433 font-size: 12.5px;
434 color: var(--text-muted);
435 font-variant-numeric: tabular-nums;
436 margin-top: 4px;
437 }
438 .mirror-last-error {
439 margin-top: 10px;
440 padding: 10px 12px;
441 background: rgba(248,113,113,0.06);
442 border: 1px solid rgba(248,113,113,0.32);
443 border-radius: 10px;
444 font-family: var(--font-mono);
445 font-size: 12px;
446 color: #fecaca;
447 overflow-x: auto;
448 white-space: pre-wrap;
449 word-break: break-word;
450 line-height: 1.45;
451 }
452 .mirror-upstream-foot {
453 margin-top: var(--space-3);
454 padding: 10px 12px;
455 border-radius: 10px;
456 background: rgba(255,255,255,0.02);
457 border: 1px dashed var(--border);
458 font-size: 12px;
459 color: var(--text-muted);
460 line-height: 1.5;
461 }
462 .mirror-upstream-foot code {
463 font-family: var(--font-mono);
464 font-size: 12px;
465 color: var(--text);
466 background: var(--bg-tertiary);
467 padding: 1px 5px;
468 border-radius: 4px;
469 }
470
471 /* ─── Empty state ─── */
472 .mirror-empty {
473 position: relative;
474 overflow: hidden;
475 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
476 text-align: center;
477 background: var(--bg-elevated);
478 border: 1px dashed var(--border-strong);
479 border-radius: 16px;
480 }
481 .mirror-empty-orb {
482 position: absolute;
483 inset: -40% 30% auto 30%;
484 height: 280px;
485 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
486 filter: blur(70px);
487 opacity: 0.7;
488 pointer-events: none;
489 z-index: 0;
490 }
491 .mirror-empty-inner { position: relative; z-index: 1; }
492 .mirror-empty-icon {
493 width: 56px; height: 56px;
494 border-radius: 9999px;
495 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
496 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
497 display: inline-flex;
498 align-items: center;
499 justify-content: center;
500 color: #c4b5fd;
501 margin-bottom: 14px;
502 }
503 .mirror-empty-title {
504 font-family: var(--font-display);
505 font-size: 18px;
506 font-weight: 700;
507 margin: 0 0 6px;
508 color: var(--text-strong);
509 }
510 .mirror-empty-sub {
511 margin: 0 auto 0;
512 font-size: 13.5px;
513 color: var(--text-muted);
514 max-width: 460px;
515 line-height: 1.5;
516 }
517
518 .mirror-403 {
519 max-width: 540px;
520 margin: var(--space-12) auto;
521 padding: var(--space-6);
522 text-align: center;
523 background: var(--bg-elevated);
524 border: 1px solid var(--border);
525 border-radius: 16px;
526 }
527 .mirror-403 h2 {
528 font-family: var(--font-display);
529 font-size: 22px;
530 margin: 0 0 8px;
531 color: var(--text-strong);
532 }
533 .mirror-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
534`;
535
536function IconMirror() {
537 return (
538 <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">
539 <polyline points="17 1 21 5 17 9" />
540 <path d="M3 11V9a4 4 0 0 1 4-4h14" />
541 <polyline points="7 23 3 19 7 15" />
542 <path d="M21 13v2a4 4 0 0 1-4 4H3" />
543 </svg>
544 );
545}
546function IconClock() {
547 return (
548 <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">
549 <circle cx="12" cy="12" r="10" />
550 <polyline points="12 6 12 12 16 14" />
551 </svg>
552 );
553}
554
555function renderForbidden(user: any) {
556 return (
557 <Layout title="Forbidden" user={user}>
558 <div class="mirror-wrap">
559 <div class="mirror-403">
560 <h2>403 — Forbidden</h2>
561 <p>Only the repository owner can configure mirroring.</p>
562 </div>
563 </div>
564 <style dangerouslySetInnerHTML={{ __html: mirrorStyles }} />
565 </Layout>
566 );
567}
568
36569async function ownerGate(c: any): Promise<
37570 | Response
38571 | {
51584 .where(eq(users.username, ownerName))
52585 .limit(1);
53586 if (!owner || owner.id !== user.id) {
54 return c.html(
55 <Layout title="Forbidden" user={user}>
56 <div class="empty-state">
57 <h2>403</h2>
58 <p>Only the repository owner can configure mirroring.</p>
59 </div>
60 </Layout>,
61 403
62 );
587 return c.html(renderForbidden(user), 403);
63588 }
64589 const [repo] = await db
65590 .select()
72597 return { user, ownerName, repoName, repo };
73598}
74599
600function statusPill(status: string | null | undefined) {
601 const s = (status || "").toLowerCase();
602 if (s === "ok" || s === "success") {
603 return (
604 <span class="mirror-pill is-ok">
605 <span class="dot" aria-hidden="true" />
606 Success
607 </span>
608 );
609 }
610 if (s === "error" || s === "failed") {
611 return (
612 <span class="mirror-pill is-error">
613 <span class="dot" aria-hidden="true" />
614 Error
615 </span>
616 );
617 }
618 if (s === "pending" || s === "running") {
619 return (
620 <span class="mirror-pill is-pending">
621 <span class="dot" aria-hidden="true" />
622 {s === "running" ? "Running" : "Pending"}
623 </span>
624 );
625 }
626 return (
627 <span class="mirror-pill is-neutral">
628 <span class="dot" aria-hidden="true" />
629 {s || "Unknown"}
630 </span>
631 );
632}
633
75634// ---------- Config page ----------
76635
77636mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
88647 return c.html(
89648 <Layout title={`Mirror — ${ownerName}/${repoName}`} user={user}>
90649 <RepoHeader owner={ownerName} repo={repoName} />
91 <div class="settings-container" style="max-width:720px">
92 <h2>Mirror settings</h2>
93 <p style="color:var(--text-muted)">
94 Keep this repository in sync with an upstream URL by periodically
95 running <code>git fetch --prune</code>. Only <code>https://</code>,{" "}
96 <code>http://</code>, and <code>git://</code> URLs are accepted —
97 SSH and local paths are not supported.
98 </p>
99
100 {success && (
101 <div class="auth-success">{decodeURIComponent(success)}</div>
102 )}
103 {error && (
104 <div class="auth-error">{decodeURIComponent(error)}</div>
105 )}
106
107 <form
108 method="post"
109 action={`/${ownerName}/${repoName}/settings/mirror`}
110 class="panel"
111 style="padding:16px;margin:16px 0"
112 >
113 <div class="form-group">
114 <label for="upstream_url">Upstream URL</label>
115 <input
116 type="text"
117 id="upstream_url"
118 name="upstream_url"
119 value={mirror?.upstreamUrl || ""}
120 placeholder="https://github.com/torvalds/linux.git"
121 required
122 style="font-family:var(--font-mono)"
123 />
124 </div>
125 <div class="form-group">
126 <label for="interval_minutes">Sync interval (minutes)</label>
127 <input
128 type="number"
129 id="interval_minutes"
130 name="interval_minutes"
131 value={mirror?.intervalMinutes ?? 1440}
132 min="5"
133 max="43200"
134 style="width:160px"
135 />
136 </div>
137 <label
138 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
139 >
140 <input
141 type="checkbox"
142 name="is_enabled"
143 value="1"
144 checked={mirror ? mirror.isEnabled : true}
145 aria-label="Enabled"
146 />
147 <span>Enabled</span>
148 </label>
149 <button type="submit" class="btn btn-primary">
150 {mirror ? "Update mirror" : "Enable mirror"}
151 </button>
152 </form>
153
154 {mirror && (
155 <>
156 <div style="display:flex;gap:8px;margin:12px 0">
650 <div class="mirror-wrap">
651 <section class="mirror-hero">
652 <div class="mirror-hero-orb" aria-hidden="true" />
653 <div class="mirror-hero-inner">
654 <div class="mirror-hero-text">
655 <div class="mirror-eyebrow">
656 <span class="mirror-eyebrow-dot" aria-hidden="true" />
657 Repository · Mirror
658 </div>
659 <h1 class="mirror-title">
660 <span class="mirror-title-grad">Track an upstream.</span>
661 </h1>
662 <p class="mirror-sub">
663 Periodically <code>git fetch --prune</code> from an upstream URL.
664 Only <code>https://</code>, <code>http://</code>, and{" "}
665 <code>git://</code> are accepted — SSH and local paths are not.
666 </p>
667 </div>
668 {mirror && (
157669 <form
158670 method="post"
159671 action={`/${ownerName}/${repoName}/settings/mirror/sync`}
160672 >
161 <button type="submit" class="btn">
673 <button type="submit" class="mirror-hero-cta">
674 <IconMirror />
162675 Sync now
163676 </button>
164677 </form>
165 <form
166 method="post"
167 action={`/${ownerName}/${repoName}/settings/mirror/delete`}
168 onsubmit="return confirm('Remove mirror configuration?')"
169 >
170 <button type="submit" class="btn btn-danger">
171 Remove mirror
172 </button>
173 </form>
678 )}
679 </div>
680 </section>
681
682 {success && (
683 <div class="mirror-banner is-ok" role="status">
684 <span class="mirror-banner-dot" aria-hidden="true" />
685 {decodeURIComponent(success)}
686 </div>
687 )}
688 {error && (
689 <div class="mirror-banner is-error" role="alert">
690 <span class="mirror-banner-dot" aria-hidden="true" />
691 {decodeURIComponent(error)}
692 </div>
693 )}
694
695 <section class="mirror-section">
696 <header class="mirror-section-head">
697 <div>
698 <h2 class="mirror-section-title">
699 <span class="mirror-section-title-icon" aria-hidden="true">
700 <IconMirror />
701 </span>
702 Mirror configuration
703 </h2>
704 <p class="mirror-section-sub">
705 Set the upstream URL and a sync cadence between 5 minutes and
706 30 days. Disable to keep the config but pause auto-sync.
707 </p>
174708 </div>
709 </header>
710 <div class="mirror-section-body">
711 <form
712 method="post"
713 action={`/${ownerName}/${repoName}/settings/mirror`}
714 >
715 <div class="mirror-field">
716 <label class="mirror-field-label" for="upstream_url">
717 Upstream URL
718 </label>
719 <input
720 type="text"
721 id="upstream_url"
722 name="upstream_url"
723 value={mirror?.upstreamUrl || ""}
724 placeholder="https://github.com/torvalds/linux.git"
725 required
726 class="mirror-input"
727 autocomplete="off"
728 spellcheck={false}
729 />
730 </div>
731 <div class="mirror-field">
732 <label class="mirror-field-label" for="interval_minutes">
733 Sync interval (minutes)
734 </label>
735 <input
736 type="number"
737 id="interval_minutes"
738 name="interval_minutes"
739 value={mirror?.intervalMinutes ?? 1440}
740 min="5"
741 max="43200"
742 class="mirror-input is-num"
743 />
744 </div>
745 <div class="mirror-field">
746 <label class="mirror-checkbox-row">
747 <input
748 type="checkbox"
749 name="is_enabled"
750 value="1"
751 checked={mirror ? mirror.isEnabled : true}
752 aria-label="Enabled"
753 />
754 <span>Enabled</span>
755 </label>
756 </div>
757 <div class="mirror-action-row">
758 <button type="submit" class="mirror-btn mirror-btn-primary">
759 {mirror ? "Update mirror" : "Enable mirror"}
760 </button>
761 {mirror && (
762 <form
763 method="post"
764 action={`/${ownerName}/${repoName}/settings/mirror/delete`}
765 onsubmit="return confirm('Remove mirror configuration?')"
766 >
767 <button type="submit" class="mirror-btn mirror-btn-danger">
768 Remove mirror
769 </button>
770 </form>
771 )}
772 </div>
773 </form>
774 </div>
775 </section>
175776
176 <h3 style="margin-top:20px">Last run</h3>
177 <div class="panel" style="padding:12px">
777 {mirror && (
778 <section class="mirror-section">
779 <header class="mirror-section-head">
780 <div>
781 <h2 class="mirror-section-title">
782 <span class="mirror-section-title-icon" aria-hidden="true">
783 <IconClock />
784 </span>
785 Last run
786 </h2>
787 <p class="mirror-section-sub">
788 Most recent fetch outcome. Errors include the raw stderr.
789 </p>
790 </div>
791 </header>
792 <div class="mirror-section-body">
178793 {mirror.lastSyncedAt ? (
179 <div>
180 <div
181 style="font-size:12px;color:var(--text-muted);text-transform:uppercase"
182 >
183 {mirror.lastStatus === "ok" ? "Success" : "Error"} —{" "}
184 {formatRelative(
185 mirror.lastSyncedAt as unknown as string
794 <div class="mirror-last">
795 <div class="mirror-last-left">
796 {statusPill(mirror.lastStatus || "ok")}
797 <div class="mirror-last-stamp">
798 {formatRelative(mirror.lastSyncedAt as unknown as string)}
799 </div>
800 {mirror.lastError && (
801 <pre class="mirror-last-error">{mirror.lastError}</pre>
186802 )}
187803 </div>
188 {mirror.lastError && (
189 <pre
190 style="margin-top:8px;padding:8px;background:var(--bg-subtle);border-radius:4px;font-size:12px;overflow-x:auto;color:var(--red)"
191 >
192 {mirror.lastError}
193 </pre>
194 )}
195804 </div>
196805 ) : (
197 <div style="color:var(--text-muted)">Never synced.</div>
806 <div style="color:var(--text-muted);font-size:13.5px">
807 Never synced. Hit <em>Sync now</em> above to run the first fetch.
808 </div>
198809 )}
199810 </div>
811 </section>
812 )}
200813
201 <h3 style="margin-top:20px">Recent runs</h3>
202 <div class="panel">
814 {mirror && (
815 <section class="mirror-section">
816 <header class="mirror-section-head">
817 <div>
818 <h2 class="mirror-section-title">
819 <span class="mirror-section-title-icon" aria-hidden="true">
820 <IconClock />
821 </span>
822 Recent runs
823 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
824 {" "}({runs.length})
825 </span>
826 </h2>
827 <p class="mirror-section-sub">
828 Latest 20 fetches, newest first.
829 </p>
830 </div>
831 </header>
832 <div class="mirror-section-body">
203833 {runs.length === 0 ? (
204 <div class="panel-empty">No runs yet.</div>
205 ) : (
206 runs.map((r) => (
207 <div
208 class="panel-item"
209 style="justify-content:space-between;flex-wrap:wrap;gap:6px"
210 >
211 <div>
212 <span
213 style={`font-size:11px;text-transform:uppercase;margin-right:8px;color:${
214 r.status === "ok"
215 ? "var(--green)"
216 : r.status === "error"
217 ? "var(--red)"
218 : "var(--text-muted)"
219 }`}
220 >
221 {r.status}
222 </span>
223 <span
224 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
225 >
226 {formatRelative(
227 r.startedAt as unknown as string
228 )}
229 </span>
834 <div class="mirror-empty">
835 <div class="mirror-empty-orb" aria-hidden="true" />
836 <div class="mirror-empty-inner">
837 <div class="mirror-empty-icon" aria-hidden="true">
838 <IconClock />
230839 </div>
231 {r.message && (
232 <span
233 style="font-size:12px;color:var(--text-muted);max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
234 title={r.message}
235 >
236 {r.message.split("\n")[0]}
237 </span>
238 )}
840 <h3 class="mirror-empty-title">No runs yet</h3>
841 <p class="mirror-empty-sub">
842 Once a sync fires (manually or on schedule) it will land
843 here with its status and timing.
844 </p>
239845 </div>
240 ))
846 </div>
847 ) : (
848 <div class="mirror-run-list">
849 {runs.map((r) => (
850 <div class="mirror-run-card">
851 {statusPill(r.status)}
852 <div class="mirror-run-meta">
853 <span>{formatRelative(r.startedAt as unknown as string)}</span>
854 </div>
855 {r.message && (
856 <span class="mirror-run-msg" title={r.message}>
857 {r.message.split("\n")[0]}
858 </span>
859 )}
860 </div>
861 ))}
862 </div>
241863 )}
864 <div class="mirror-upstream-foot">
865 Upstream (logged, credentials redacted):{" "}
866 <code>{safeUrlForLog(mirror.upstreamUrl)}</code>
867 </div>
242868 </div>
243 <p
244 style="font-size:12px;color:var(--text-muted);margin-top:12px"
245 >
246 Upstream (logged, credentials redacted):{" "}
247 <code>{safeUrlForLog(mirror.upstreamUrl)}</code>
248 </p>
249 </>
869 </section>
250870 )}
251871 </div>
872 <style dangerouslySetInnerHTML={{ __html: mirrorStyles }} />
252873 </Layout>
253874 );
254875});
366987 if (!(await isSiteAdmin(user.id))) {
367988 return c.html(
368989 <Layout title="Forbidden" user={user}>
369 <div class="empty-state">
370 <h2>403</h2>
371 <p>Site admin only.</p>
990 <div class="mirror-wrap">
991 <div class="mirror-403">
992 <h2>403 — Forbidden</h2>
993 <p>Site admin only.</p>
994 </div>
372995 </div>
996 <style dangerouslySetInnerHTML={{ __html: mirrorStyles }} />
373997 </Layout>,
374998 403
375999 );
Modifiedsrc/routes/specs.tsx+473−111View fileUnifiedSplit
1111 * parallel. We import it dynamically so this file compiles and its tests
1212 * pass even if the module is not yet on disk — if the import fails we
1313 * fall back to a "Backend not available" banner.
14 *
15 * 2026 polish: scoped `.specs-*` class system. Eyebrow + display headline +
16 * subtitle hero, card sections for the form + the "how this works" steps,
17 * primary CTA on the submit button. All form fields, names, actions, and
18 * the dynamic-import behaviour are unchanged.
1419 */
1520import { Hono } from "hono";
1621import { and, eq } from "drizzle-orm";
2126import { softAuth, requireAuth } from "../middleware/auth";
2227import type { AuthEnv } from "../middleware/auth";
2328import { listBranches } from "../git/repository";
24import {
25 Alert,
26 Button,
27 Container,
28 EmptyState,
29 Form,
30 FormGroup,
31 Select,
32 TextArea,
33 Text,
34} from "../views/ui";
3529
3630const specs = new Hono<AuthEnv>();
3731
4741 var ta = form.querySelector('textarea[name="spec"]');
4842 if (btn) {
4943 btn.disabled = true;
50 btn.textContent = 'Working... this can take 10-30s';
44 btn.textContent = 'Working… this can take 10-30s';
5145 }
5246 if (ta) ta.readOnly = true;
5347 });
5448})();
5549`;
5650
51// ─── Scoped CSS (.specs-*) ─────────────────────────────────────────────────
52const specsStyles = `
53 .specs-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
54
55 /* ─── Header ─── */
56 .specs-head { margin-bottom: var(--space-5); }
57 .specs-eyebrow {
58 display: inline-flex;
59 align-items: center;
60 gap: 8px;
61 text-transform: uppercase;
62 font-family: var(--font-mono);
63 font-size: 11px;
64 letter-spacing: 0.16em;
65 color: var(--text-muted);
66 font-weight: 600;
67 margin-bottom: 10px;
68 }
69 .specs-eyebrow-dot {
70 width: 8px; height: 8px;
71 border-radius: 9999px;
72 background: linear-gradient(135deg, #8c6dff, #36c5d6);
73 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
74 }
75 .specs-pill-experimental {
76 display: inline-flex;
77 align-items: center;
78 gap: 5px;
79 padding: 2px 8px;
80 margin-left: 4px;
81 border-radius: 9999px;
82 font-size: 10px;
83 font-weight: 700;
84 letter-spacing: 0.06em;
85 background: rgba(251,191,36,0.12);
86 color: #fde68a;
87 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
88 }
89 .specs-title {
90 font-family: var(--font-display);
91 font-size: clamp(26px, 3.6vw, 40px);
92 font-weight: 800;
93 letter-spacing: -0.028em;
94 line-height: 1.05;
95 margin: 0 0 6px;
96 color: var(--text-strong);
97 }
98 .specs-title-grad {
99 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
100 -webkit-background-clip: text;
101 background-clip: text;
102 -webkit-text-fill-color: transparent;
103 color: transparent;
104 }
105 .specs-sub {
106 margin: 0;
107 font-size: 14px;
108 color: var(--text-muted);
109 line-height: 1.5;
110 max-width: 720px;
111 }
112 .specs-sub a { color: var(--accent); text-decoration: none; }
113 .specs-sub a:hover { text-decoration: underline; }
114 .specs-sub code {
115 font-family: var(--font-mono);
116 font-size: 12px;
117 background: rgba(255,255,255,0.04);
118 padding: 1px 6px;
119 border-radius: 4px;
120 }
121
122 /* ─── Banners ─── */
123 .specs-banner {
124 margin-bottom: var(--space-4);
125 padding: 10px 14px;
126 border-radius: 10px;
127 font-size: 13.5px;
128 border: 1px solid var(--border);
129 background: rgba(255,255,255,0.025);
130 color: var(--text);
131 display: flex;
132 align-items: flex-start;
133 gap: 10px;
134 line-height: 1.45;
135 }
136 .specs-banner.is-info {
137 border-color: rgba(54,197,214,0.40);
138 background: rgba(54,197,214,0.08);
139 color: #cffafe;
140 }
141 .specs-banner.is-error {
142 border-color: rgba(248,113,113,0.40);
143 background: rgba(248,113,113,0.08);
144 color: #fecaca;
145 }
146 .specs-banner-dot {
147 width: 8px; height: 8px;
148 border-radius: 9999px;
149 background: currentColor;
150 margin-top: 6px;
151 flex-shrink: 0;
152 }
153 .specs-banner a {
154 color: inherit;
155 text-decoration: underline;
156 font-weight: 600;
157 }
158
159 /* ─── Section card ─── */
160 .specs-section {
161 margin-bottom: var(--space-5);
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 14px;
165 overflow: hidden;
166 position: relative;
167 }
168 .specs-section::before {
169 content: '';
170 position: absolute;
171 top: 0; left: 0; right: 0;
172 height: 2px;
173 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
174 opacity: 0.55;
175 pointer-events: none;
176 }
177 .specs-section-head {
178 padding: var(--space-4) var(--space-5) var(--space-3);
179 border-bottom: 1px solid var(--border);
180 }
181 .specs-section-title {
182 margin: 0;
183 font-family: var(--font-display);
184 font-size: 16px;
185 font-weight: 700;
186 letter-spacing: -0.018em;
187 color: var(--text-strong);
188 }
189 .specs-section-sub {
190 margin: 6px 0 0;
191 font-size: 12.5px;
192 color: var(--text-muted);
193 line-height: 1.45;
194 }
195 .specs-section-body {
196 padding: var(--space-4) var(--space-5);
197 }
198
199 /* ─── Form fields ─── */
200 .specs-field { margin-bottom: var(--space-4); }
201 .specs-field:last-child { margin-bottom: 0; }
202 .specs-field-label {
203 display: block;
204 font-size: 11.5px;
205 font-weight: 600;
206 text-transform: uppercase;
207 letter-spacing: 0.06em;
208 color: var(--text-muted);
209 margin-bottom: 6px;
210 }
211 .specs-input,
212 .specs-select,
213 .specs-textarea {
214 width: 100%;
215 box-sizing: border-box;
216 padding: 10px 12px;
217 font: inherit;
218 font-size: 13.5px;
219 color: var(--text);
220 background: rgba(255,255,255,0.03);
221 border: 1px solid var(--border-strong);
222 border-radius: 10px;
223 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
224 }
225 .specs-textarea {
226 font-family: var(--font-mono);
227 font-size: 13px;
228 line-height: 1.55;
229 resize: vertical;
230 min-height: 200px;
231 }
232 .specs-input:focus,
233 .specs-select:focus,
234 .specs-textarea:focus {
235 outline: none;
236 border-color: rgba(140,109,255,0.55);
237 background: rgba(255,255,255,0.05);
238 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
239 }
240 .specs-select {
241 appearance: none;
242 padding-right: 30px;
243 background-image:
244 linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
245 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
246 background-position: right 12px top 50%, right 7px top 50%;
247 background-size: 5px 5px, 5px 5px;
248 background-repeat: no-repeat;
249 }
250 .specs-field-hint {
251 margin-top: 6px;
252 font-size: 11.5px;
253 color: var(--text-muted);
254 line-height: 1.45;
255 }
256
257 /* ─── Buttons ─── */
258 .specs-actions {
259 display: flex;
260 align-items: center;
261 gap: 10px;
262 flex-wrap: wrap;
263 padding-top: 4px;
264 }
265 .specs-btn {
266 display: inline-flex;
267 align-items: center;
268 justify-content: center;
269 gap: 8px;
270 padding: 10px 18px;
271 border-radius: 10px;
272 font-size: 13.5px;
273 font-weight: 600;
274 text-decoration: none;
275 border: 1px solid transparent;
276 cursor: pointer;
277 font: inherit;
278 line-height: 1;
279 white-space: nowrap;
280 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
281 }
282 .specs-btn-primary {
283 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
284 color: #ffffff;
285 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
286 }
287 .specs-btn-primary:hover {
288 transform: translateY(-1px);
289 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
290 color: #ffffff;
291 text-decoration: none;
292 }
293 .specs-btn-primary:disabled {
294 cursor: not-allowed;
295 opacity: 0.6;
296 transform: none;
297 box-shadow: none;
298 }
299 .specs-actions-hint {
300 font-size: 12px;
301 color: var(--text-muted);
302 }
303
304 /* ─── How-this-works step cards ─── */
305 .specs-steps {
306 display: grid;
307 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
308 gap: 10px;
309 }
310 .specs-step {
311 padding: 14px;
312 background: rgba(255,255,255,0.018);
313 border: 1px solid var(--border);
314 border-radius: 11px;
315 transition: border-color 120ms ease, background 120ms ease;
316 }
317 .specs-step:hover {
318 border-color: var(--border-strong);
319 background: rgba(255,255,255,0.03);
320 }
321 .specs-step-num {
322 display: inline-flex;
323 align-items: center;
324 justify-content: center;
325 width: 24px; height: 24px;
326 border-radius: 7px;
327 background: rgba(140,109,255,0.16);
328 color: #c4b5fd;
329 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
330 font-family: var(--font-mono);
331 font-size: 12px;
332 font-weight: 700;
333 margin-bottom: 10px;
334 }
335 .specs-step-title {
336 margin: 0 0 4px;
337 font-family: var(--font-display);
338 font-size: 13.5px;
339 font-weight: 700;
340 color: var(--text-strong);
341 letter-spacing: -0.005em;
342 }
343 .specs-step-body {
344 margin: 0;
345 font-size: 12.5px;
346 color: var(--text-muted);
347 line-height: 1.5;
348 }
349 .specs-step-body code {
350 font-family: var(--font-mono);
351 font-size: 11.5px;
352 background: rgba(255,255,255,0.04);
353 padding: 1px 5px;
354 border-radius: 4px;
355 color: var(--text);
356 }
357
358 /* ─── 403 / not-found ─── */
359 .specs-empty {
360 max-width: 540px;
361 margin: var(--space-8) auto;
362 padding: var(--space-6);
363 text-align: center;
364 background: var(--bg-elevated);
365 border: 1px dashed var(--border-strong);
366 border-radius: 16px;
367 }
368 .specs-empty h2 {
369 font-family: var(--font-display);
370 font-size: 20px;
371 margin: 0 0 8px;
372 color: var(--text-strong);
373 }
374 .specs-empty p {
375 margin: 0;
376 color: var(--text-muted);
377 font-size: 13.5px;
378 }
379`;
380
57381interface ResolvedRepo {
58382 ownerId: string;
59383 ownerUsername: string;
166490 ? baseRef
167491 : defaultBranch;
168492 return (
169 <Container maxWidth={820}>
170 <div
171 class="panel"
172 style="padding:14px 16px;margin-bottom:20px;border-left:3px solid var(--accent)"
173 >
174 <strong>Experimental</strong>
175 {" — "}
176 AI-generated PRs are draft by default. Review every line before
177 merging.
178 </div>
493 <div class="specs-wrap">
494 <header class="specs-head">
495 <div class="specs-eyebrow">
496 <span class="specs-eyebrow-dot" aria-hidden="true" />
497 Repository · Spec to PR
498 <span class="specs-pill-experimental">Experimental</span>
499 </div>
500 <h1 class="specs-title">
501 <span class="specs-title-grad">Describe it. Ship a draft.</span>
502 </h1>
503 <p class="specs-sub">
504 Write a feature in plain English. Claude drafts the code changes
505 and opens a pull request against the branch you pick. Every PR is{" "}
506 <strong>draft by default</strong> — review every line before merging.
507 </p>
508 </header>
179509
180510 {fromIssueNumber && (
181 <Alert variant="info">
182 Building from issue{" "}
183 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
184 #{fromIssueNumber}
185 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
186 </a>
187 . The spec below has been pre-filled from the issue and will
188 auto-close it on merge.
189 </Alert>
511 <div class="specs-banner is-info" role="status">
512 <span class="specs-banner-dot" aria-hidden="true" />
513 <span>
514 Building from issue{" "}
515 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
516 #{fromIssueNumber}
517 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
518 </a>
519 . The spec below has been pre-filled and will auto-close the
520 issue on merge.
521 </span>
522 </div>
190523 )}
191524
192 <h2 style="margin-bottom:4px">Spec to PR</h2>
193 <Text muted style="display:block;margin-bottom:16px">
194 Describe a feature in plain English. Claude will draft the code
195 changes and open a pull request against the branch you choose.
196 </Text>
197
198 {error && <Alert variant="error">{error}</Alert>}
199
200 <Form
201 method="post"
202 action={`/${ownerName}/${repoName}/spec`}
203 id="spec-form"
204 >
205 <FormGroup label="Feature spec" htmlFor="spec">
206 <TextArea
207 name="spec"
208 id="spec"
209 rows={10}
210 required
211 value={spec || ""}
212 placeholder="add a dark mode toggle to the settings page"
213 />
214 </FormGroup>
215
216 <FormGroup label="Base branch" htmlFor="baseRef">
217 <Select name="baseRef" id="baseRef" value={selectedBase}>
218 {branchList.map((b) => (
219 <option value={b} selected={b === selectedBase}>
220 {b}
221 </option>
222 ))}
223 </Select>
224 </FormGroup>
225
226 <Button type="submit" variant="primary">
227 Generate PR with AI
228 </Button>
229 </Form>
230
231 <div class="panel" style="margin-top:28px">
232 <div
233 class="panel-item"
234 style="flex-direction:column;align-items:flex-start;gap:4px;padding:14px 16px"
235 >
236 <strong>How this works</strong>
237 </div>
238 <div class="panel-item" style="padding:12px 16px">
239 <div>
240 <strong>1. You write a spec.</strong>
241 {" "}
242 <Text muted>
243 A sentence or a paragraph describing the change you want.
244 </Text>
245 </div>
525 {error && (
526 <div class="specs-banner is-error" role="alert">
527 <span class="specs-banner-dot" aria-hidden="true" />
528 <span>{error}</span>
246529 </div>
247 <div class="panel-item" style="padding:12px 16px">
248 <div>
249 <strong>2. Claude drafts the diff.</strong>
250 {" "}
251 <Text muted>
252 We fetch the base branch, run Claude against the repo, and
253 commit the proposed changes to a new branch.
254 </Text>
255 </div>
530 )}
531
532 <section class="specs-section">
533 <header class="specs-section-head">
534 <h2 class="specs-section-title">Feature spec</h2>
535 <p class="specs-section-sub">
536 One sentence or a paragraph. Be specific about files, behaviour,
537 or success criteria when you can.
538 </p>
539 </header>
540 <div class="specs-section-body">
541 <form
542 method="post"
543 action={`/${ownerName}/${repoName}/spec`}
544 id="spec-form"
545 >
546 <div class="specs-field">
547 <label class="specs-field-label" for="spec">What do you want built?</label>
548 <textarea
549 class="specs-textarea"
550 name="spec"
551 id="spec"
552 rows={10}
553 required
554 placeholder="add a dark mode toggle to the settings page"
555 >{spec || ""}</textarea>
556 </div>
557
558 <div class="specs-field">
559 <label class="specs-field-label" for="baseRef">Base branch</label>
560 <select
561 class="specs-select"
562 name="baseRef"
563 id="baseRef"
564 value={selectedBase}
565 >
566 {branchList.map((b) => (
567 <option value={b} selected={b === selectedBase}>
568 {b}
569 </option>
570 ))}
571 </select>
572 <p class="specs-field-hint">
573 The draft PR will target this branch. Nothing lands without
574 your approval.
575 </p>
576 </div>
577
578 <div class="specs-actions">
579 <button type="submit" class="specs-btn specs-btn-primary">
580 Generate PR with AI
581 </button>
582 <span class="specs-actions-hint">Typically 10-30 seconds.</span>
583 </div>
584 </form>
256585 </div>
257 <div class="panel-item" style="padding:12px 16px">
258 <div>
259 <strong>3. A draft PR opens.</strong>
260 {" "}
261 <Text muted>
262 You review, edit, and merge on your terms. Nothing lands on
263 {" "}
264 <code>{selectedBase}</code> automatically.
265 </Text>
586 </section>
587
588 <section class="specs-section">
589 <header class="specs-section-head">
590 <h2 class="specs-section-title">How this works</h2>
591 <p class="specs-section-sub">
592 Three steps from spec to mergeable diff — all observable, all
593 reversible.
594 </p>
595 </header>
596 <div class="specs-section-body">
597 <div class="specs-steps">
598 <div class="specs-step">
599 <span class="specs-step-num">1</span>
600 <h3 class="specs-step-title">You write a spec.</h3>
601 <p class="specs-step-body">
602 A sentence or a paragraph describing the change you want.
603 </p>
604 </div>
605 <div class="specs-step">
606 <span class="specs-step-num">2</span>
607 <h3 class="specs-step-title">Claude drafts the diff.</h3>
608 <p class="specs-step-body">
609 We fetch the base branch, run Claude against the repo, and
610 commit the proposed changes to a new branch.
611 </p>
612 </div>
613 <div class="specs-step">
614 <span class="specs-step-num">3</span>
615 <h3 class="specs-step-title">A draft PR opens.</h3>
616 <p class="specs-step-body">
617 You review, edit, and merge on your terms. Nothing lands on{" "}
618 <code>{selectedBase}</code> automatically.
619 </p>
620 </div>
266621 </div>
267622 </div>
268 </div>
623 </section>
269624
270625 <script dangerouslySetInnerHTML={{ __html: DISABLE_ON_SUBMIT_JS }} />
271 </Container>
626 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
627 </div>
272628 );
273629}
274630
280636 if (!resolved) {
281637 return c.html(
282638 <Layout title="Not Found" user={user}>
283 <EmptyState title="Repository not found">
639 <div class="specs-empty">
640 <h2>Repository not found</h2>
284641 <p>No such repository.</p>
285 </EmptyState>
642 </div>
643 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
286644 </Layout>,
287645 404
288646 );
292650 return c.html(
293651 <Layout title="Forbidden" user={user}>
294652 <RepoHeader owner={owner} repo={repo} />
295 <EmptyState title="Write access required">
653 <div class="specs-empty">
654 <h2>Write access required</h2>
296655 <p>You need write access to generate a spec-to-PR on this repository.</p>
297 </EmptyState>
656 </div>
657 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
298658 </Layout>,
299659 403
300660 );
367727 return c.html(
368728 <Layout title="Forbidden" user={user}>
369729 <RepoHeader owner={owner} repo={repo} />
370 <EmptyState title="Write access required">
730 <div class="specs-empty">
731 <h2>Write access required</h2>
371732 <p>You need write access to generate a spec-to-PR on this repository.</p>
372 </EmptyState>
733 </div>
734 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
373735 </Layout>,
374736 403
375737 );
376738