CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
demo.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 52ad8b1 | 1 | /** |
| 2 | * Block L3 — public `/demo` landing page + companion JSON endpoints. | |
| 3 | * | |
| 4 | * Anonymous-friendly. Demonstrates Gluecron's AI features in real time by | |
| 5 | * surfacing live counts off the audit log + `pr_comments` table for the | |
| 6 | * seeded demo repos. | |
| 7 | * | |
| 8 | * Routes mounted from `src/app.tsx`. Must come BEFORE `routes/admin.tsx` | |
| 9 | * in mount order so this handler wins over the legacy `/demo` redirect. | |
| 10 | * | |
| 11 | * GET /demo → SSR landing page (graceful no-JS, | |
| 12 | * polls JSON endpoints every 30s | |
| 13 | * when JS is available) | |
| 14 | * GET /api/v2/demo/activity → combined activity feed JSON | |
| 15 | * GET /api/v2/demo/queued → queued ai:build issues JSON | |
| 16 | * GET /api/v2/demo/merges → recent auto-merges JSON | |
| 17 | * GET /api/v2/demo/reviews → recent AI reviews JSON | |
| 18 | * | |
| 19 | * All JSON endpoints serve `Cache-Control: public, max-age=30` so the demo | |
| 20 | * page is cheap to refresh and external dashboards can embed safely. | |
| 21 | */ | |
| 22 | ||
| 23 | import { Hono } from "hono"; | |
| 24 | import { Layout } from "../views/layout"; | |
| 25 | import { softAuth } from "../middleware/auth"; | |
| 26 | import type { AuthEnv } from "../middleware/auth"; | |
| 27 | import { DEMO_USERNAME } from "../lib/demo-seed"; | |
| 28 | import { | |
| 29 | countAiReviewsSince, | |
| 30 | listDemoActivityFeed, | |
| 31 | listQueuedAiBuildIssues, | |
| 32 | listRecentAiReviews, | |
| 33 | listRecentAutoMerges, | |
| 34 | type DemoActivityEntry, | |
| 35 | } from "../lib/demo-activity"; | |
| 36 | ||
| 37 | const app = new Hono<AuthEnv>(); | |
| 38 | ||
| 39 | const POLL_INTERVAL_MS = 30_000; | |
| 40 | ||
| 41 | function jsonCacheHeaders(): Record<string, string> { | |
| 42 | return { | |
| 43 | "Content-Type": "application/json; charset=utf-8", | |
| 44 | "Cache-Control": "public, max-age=30", | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 48 | // ─────────────────────────────────────────────────────────────────── | |
| 49 | // JSON endpoints — cheap, public, cacheable. | |
| 50 | // ─────────────────────────────────────────────────────────────────── | |
| 51 | ||
| 52 | app.get("/api/v2/demo/activity", async (c) => { | |
| 53 | const feed = await listDemoActivityFeed(20); | |
| 54 | return new Response( | |
| 55 | JSON.stringify({ | |
| 56 | entries: feed.map((e) => ({ | |
| 57 | kind: e.kind, | |
| 58 | repo: e.repo, | |
| 59 | ref: e.ref, | |
| 60 | at: e.at.toISOString(), | |
| 61 | })), | |
| 62 | }), | |
| 63 | { status: 200, headers: jsonCacheHeaders() } | |
| 64 | ); | |
| 65 | }); | |
| 66 | ||
| 67 | app.get("/api/v2/demo/queued", async (c) => { | |
| 68 | const items = await listQueuedAiBuildIssues(5); | |
| 69 | return new Response( | |
| 70 | JSON.stringify({ | |
| 71 | items: items.map((i) => ({ | |
| 72 | repo: i.repo, | |
| 73 | number: i.number, | |
| 74 | title: i.title, | |
| 75 | createdAt: i.createdAt.toISOString(), | |
| 76 | })), | |
| 77 | }), | |
| 78 | { status: 200, headers: jsonCacheHeaders() } | |
| 79 | ); | |
| 80 | }); | |
| 81 | ||
| 82 | app.get("/api/v2/demo/merges", async (c) => { | |
| 83 | const items = await listRecentAutoMerges(5, 24); | |
| 84 | return new Response( | |
| 85 | JSON.stringify({ | |
| 86 | items: items.map((m) => ({ | |
| 87 | repo: m.repo, | |
| 88 | number: m.number, | |
| 89 | title: m.title, | |
| 90 | mergedAt: m.mergedAt.toISOString(), | |
| 91 | })), | |
| 92 | }), | |
| 93 | { status: 200, headers: jsonCacheHeaders() } | |
| 94 | ); | |
| 95 | }); | |
| 96 | ||
| 97 | app.get("/api/v2/demo/reviews", async (c) => { | |
| 98 | const [items, count] = await Promise.all([ | |
| 99 | listRecentAiReviews(5, 24), | |
| 100 | countAiReviewsSince(24), | |
| 101 | ]); | |
| 102 | return new Response( | |
| 103 | JSON.stringify({ | |
| 104 | count, | |
| 105 | items: items.map((r) => ({ | |
| 106 | repo: r.repo, | |
| 107 | prNumber: r.prNumber, | |
| 108 | commentSnippet: r.commentSnippet, | |
| 109 | createdAt: r.createdAt.toISOString(), | |
| 110 | })), | |
| 111 | }), | |
| 112 | { status: 200, headers: jsonCacheHeaders() } | |
| 113 | ); | |
| 114 | }); | |
| 115 | ||
| 116 | // ─────────────────────────────────────────────────────────────────── | |
| 117 | // HTML landing page. | |
| 118 | // ─────────────────────────────────────────────────────────────────── | |
| 119 | ||
| 120 | function escapeHtml(s: string): string { | |
| 121 | return String(s).replace(/[&<>"']/g, (c) => | |
| 122 | c === "&" | |
| 123 | ? "&" | |
| 124 | : c === "<" | |
| 125 | ? "<" | |
| 126 | : c === ">" | |
| 127 | ? ">" | |
| 128 | : c === '"' | |
| 129 | ? """ | |
| 130 | : "'" | |
| 131 | ); | |
| 132 | } | |
| 133 | ||
| 134 | function relativeTime(d: Date): string { | |
| 135 | const ms = Date.now() - d.getTime(); | |
| 136 | const s = Math.max(0, Math.floor(ms / 1000)); | |
| 137 | if (s < 60) return `${s}s ago`; | |
| 138 | const m = Math.floor(s / 60); | |
| 139 | if (m < 60) return `${m}m ago`; | |
| 140 | const h = Math.floor(m / 60); | |
| 141 | if (h < 48) return `${h}h ago`; | |
| 142 | const days = Math.floor(h / 24); | |
| 143 | return `${days}d ago`; | |
| 144 | } | |
| 145 | ||
| 146 | function activityLabel(kind: DemoActivityEntry["kind"]): string { | |
| 147 | switch (kind) { | |
| 148 | case "auto_merge.merged": | |
| 149 | return "auto-merged"; | |
| 150 | case "ai_build.dispatched": | |
| 151 | return "AI-build dispatched"; | |
| 152 | case "ai_review.posted": | |
| 153 | return "AI review posted"; | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | app.get("/demo", softAuth, async (c) => { | |
| 158 | const user = c.get("user") ?? null; | |
| 159 | ||
| 160 | // Server-side render the initial snapshot. The JS poller refreshes it | |
| 161 | // every 30s but the no-JS experience still works. | |
| 162 | const [queued, merges, reviewsCountAndList, feed] = await Promise.all([ | |
| 163 | listQueuedAiBuildIssues(5), | |
| 164 | listRecentAutoMerges(5, 24), | |
| 165 | Promise.all([listRecentAiReviews(5, 24), countAiReviewsSince(24)]), | |
| 166 | listDemoActivityFeed(20), | |
| 167 | ]); | |
| 168 | const [reviews, reviewCount] = reviewsCountAndList; | |
| 169 | ||
| 170 | const demoUserUrl = `/${DEMO_USERNAME}`; | |
| 171 | const demoRepos: { name: string; tagline: string }[] = [ | |
| 172 | { name: "hello-python", tagline: "Tiny Python app — labels, issues, gates." }, | |
| 173 | { name: "todo-api", tagline: "Hono todo API — PRs, AI review, auto-merge." }, | |
| 174 | { name: "design-docs", tagline: "ADRs + architecture docs." }, | |
| 175 | ]; | |
| 176 | ||
| 177 | // Poller — refreshes the four tiles every 30s. Plain vanilla JS, no | |
| 178 | // framework. Mirrors the same pattern as `src/lib/sse-client.ts`. | |
| 179 | const pollerScript = ` | |
| 180 | (function(){try{ | |
| 181 | var INTERVAL=${POLL_INTERVAL_MS}; | |
| 182 | function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} | |
| 183 | function rel(iso){try{var ms=Date.now()-new Date(iso).getTime();var s=Math.max(0,Math.floor(ms/1000));if(s<60)return s+'s ago';var m=Math.floor(s/60);if(m<60)return m+'m ago';var h=Math.floor(m/60);if(h<48)return h+'h ago';return Math.floor(h/24)+'d ago';}catch(e){return '';}} | |
| 184 | function pollQueued(){fetch('/api/v2/demo/queued').then(function(r){return r.json();}).then(function(d){ | |
| 185 | var el=document.getElementById('tile-queued-list');if(!el)return; | |
| 186 | if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No queued AI builds — quiet right now.</li>';return;} | |
| 187 | el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/issues/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join(''); | |
| 188 | }).catch(function(){});} | |
| 189 | function pollMerges(){fetch('/api/v2/demo/merges').then(function(r){return r.json();}).then(function(d){ | |
| 190 | var el=document.getElementById('tile-merges-list');if(!el)return; | |
| 191 | if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No auto-merges in the last 24h.</li>';return;} | |
| 192 | el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.mergedAt)+'</span></li>';}).join(''); | |
| 193 | }).catch(function(){});} | |
| 194 | function pollReviews(){fetch('/api/v2/demo/reviews').then(function(r){return r.json();}).then(function(d){ | |
| 195 | var c=document.getElementById('tile-reviews-count');if(c)c.textContent=String(d.count||0); | |
| 196 | var el=document.getElementById('tile-reviews-list');if(!el)return; | |
| 197 | if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No AI reviews in the last 24h.</li>';return;} | |
| 198 | el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.prNumber+'">#'+i.prNumber+'</a> <span class="demo-snippet">'+esc(i.commentSnippet)+'</span> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join(''); | |
| 199 | }).catch(function(){});} | |
| 200 | function pollFeed(){fetch('/api/v2/demo/activity').then(function(r){return r.json();}).then(function(d){ | |
| 201 | var el=document.getElementById('demo-feed-list');if(!el)return; | |
| 202 | if(!d.entries||d.entries.length===0){el.innerHTML='<li class="demo-empty">Quiet right now — push a commit to a demo repo to see live updates.</li>';return;} | |
| 203 | el.innerHTML=d.entries.map(function(e){var label=e.kind==='auto_merge.merged'?'auto-merged':(e.kind==='ai_build.dispatched'?'AI-build dispatched':'AI review posted');var path=e.ref.type==='pr'?'pulls':'issues';return '<li><span class="demo-kind demo-kind-'+esc(e.kind.replace(/\\./g,'-'))+'">'+esc(label)+'</span> <a href="/${DEMO_USERNAME}/'+esc(e.repo)+'/'+path+'/'+e.ref.number+'">'+esc(e.repo)+' #'+e.ref.number+'</a> <span class="demo-meta">'+rel(e.at)+'</span></li>';}).join(''); | |
| 204 | }).catch(function(){});} | |
| 205 | function tick(){pollQueued();pollMerges();pollReviews();pollFeed();} | |
| 206 | // Initial refresh after a short delay so the SSR snapshot stays visible | |
| 207 | // a beat — keeps the page from "flashing" identical content on load. | |
| 208 | setInterval(tick,INTERVAL); | |
| 209 | }catch(e){}})(); | |
| 210 | `.trim(); | |
| 211 | ||
| 212 | return c.html( | |
| 213 | <Layout title="Live demo" user={user}> | |
| 214 | <style dangerouslySetInnerHTML={{ __html: DEMO_CSS }} /> | |
| 215 | <div class="demo-page"> | |
| 216 | <div class="demo-hero"> | |
| 217 | <div class="demo-hero-inner"> | |
| 218 | <div class="eyebrow"> | |
| 219 | <span class="demo-pulse" /> | |
| 220 | Live · pulled from production · refreshes every 30s | |
| 221 | </div> | |
| 222 | <h1 class="demo-title"> | |
| 223 | Watch Claude <span class="gradient-text">build software, live.</span> | |
| 224 | </h1> | |
| 225 | <p class="demo-sub"> | |
| 226 | Every tile below is real audit data from the seeded demo repos. | |
| 227 | File an issue tagged <code>ai:build</code> and the gluecron autopilot | |
| 228 | opens a PR. Land an AI review. Merge it automatically when gates | |
| 229 | go green. No human in the loop for the routine cases. | |
| 230 | </p> | |
| 231 | </div> | |
| 232 | <div class="demo-hero-cta"> | |
| 233 | <a href="/register" class="btn btn-primary btn-lg"> | |
| 234 | Sign up free <span aria-hidden="true">{"→"}</span> | |
| 235 | </a> | |
| 236 | </div> | |
| 237 | </div> | |
| 238 | ||
| 239 | <div class="demo-tiles"> | |
| 240 | <section class="demo-tile" aria-labelledby="tile-queued-h"> | |
| 241 | <h2 id="tile-queued-h" class="demo-tile-title"> | |
| 242 | Issues queued for AI build | |
| 243 | </h2> | |
| 244 | <ul id="tile-queued-list" class="demo-list"> | |
| 245 | {queued.length === 0 ? ( | |
| 246 | <li class="demo-empty">No queued AI builds — quiet right now.</li> | |
| 247 | ) : ( | |
| 248 | queued.map((i) => ( | |
| 249 | <li> | |
| 250 | <a href={`/${DEMO_USERNAME}/${i.repo}/issues/${i.number}`}> | |
| 251 | #{i.number} {i.title} | |
| 252 | </a>{" "} | |
| 253 | <span class="demo-meta"> | |
| 254 | {i.repo} · {relativeTime(i.createdAt)} | |
| 255 | </span> | |
| 256 | </li> | |
| 257 | )) | |
| 258 | )} | |
| 259 | </ul> | |
| 260 | </section> | |
| 261 | ||
| 262 | <section class="demo-tile" aria-labelledby="tile-merges-h"> | |
| 263 | <h2 id="tile-merges-h" class="demo-tile-title"> | |
| 264 | PRs auto-merged in the last 24h | |
| 265 | </h2> | |
| 266 | <ul id="tile-merges-list" class="demo-list"> | |
| 267 | {merges.length === 0 ? ( | |
| 268 | <li class="demo-empty"> | |
| 269 | No auto-merges in the last 24h. | |
| 270 | </li> | |
| 271 | ) : ( | |
| 272 | merges.map((m) => ( | |
| 273 | <li> | |
| 274 | <a href={`/${DEMO_USERNAME}/${m.repo}/pulls/${m.number}`}> | |
| 275 | #{m.number} {m.title} | |
| 276 | </a>{" "} | |
| 277 | <span class="demo-meta"> | |
| 278 | {m.repo} · {relativeTime(m.mergedAt)} | |
| 279 | </span> | |
| 280 | </li> | |
| 281 | )) | |
| 282 | )} | |
| 283 | </ul> | |
| 284 | </section> | |
| 285 | ||
| 286 | <section class="demo-tile" aria-labelledby="tile-reviews-h"> | |
| 287 | <h2 id="tile-reviews-h" class="demo-tile-title"> | |
| 288 | AI reviews posted today | |
| 289 | </h2> | |
| 290 | <div class="demo-bigcount"> | |
| 291 | <span id="tile-reviews-count">{reviewCount}</span> | |
| 292 | <span class="demo-bigcount-label">in the last 24h</span> | |
| 293 | </div> | |
| 294 | <ul id="tile-reviews-list" class="demo-list demo-list-small"> | |
| 295 | {reviews.length === 0 ? ( | |
| 296 | <li class="demo-empty"> | |
| 297 | No AI reviews in the last 24h. | |
| 298 | </li> | |
| 299 | ) : ( | |
| 300 | reviews.map((r) => ( | |
| 301 | <li> | |
| 302 | <a href={`/${DEMO_USERNAME}/${r.repo}/pulls/${r.prNumber}`}> | |
| 303 | #{r.prNumber} | |
| 304 | </a>{" "} | |
| 305 | <span class="demo-snippet">{r.commentSnippet}</span>{" "} | |
| 306 | <span class="demo-meta"> | |
| 307 | {r.repo} · {relativeTime(r.createdAt)} | |
| 308 | </span> | |
| 309 | </li> | |
| 310 | )) | |
| 311 | )} | |
| 312 | </ul> | |
| 313 | </section> | |
| 314 | </div> | |
| 315 | ||
| 316 | <section class="demo-repos" aria-labelledby="demo-repos-h"> | |
| 317 | <h2 id="demo-repos-h" class="demo-section-title"> | |
| 318 | Dig into the demo repos | |
| 319 | </h2> | |
| 320 | <div class="demo-repos-grid"> | |
| 321 | {demoRepos.map((r) => ( | |
| 322 | <a class="demo-repo-card" href={`${demoUserUrl}/${r.name}`}> | |
| 323 | <div class="demo-repo-name"> | |
| 324 | {DEMO_USERNAME}/{r.name} | |
| 325 | </div> | |
| 326 | <div class="demo-repo-tag">{r.tagline}</div> | |
| 327 | </a> | |
| 328 | ))} | |
| 329 | </div> | |
| 330 | </section> | |
| 331 | ||
| 332 | <section class="demo-feed" aria-labelledby="demo-feed-h"> | |
| 333 | <h2 id="demo-feed-h" class="demo-section-title"> | |
| 334 | Live activity | |
| 335 | </h2> | |
| 336 | <ul id="demo-feed-list" class="demo-feed-list"> | |
| 337 | {feed.length === 0 ? ( | |
| 338 | <li class="demo-empty"> | |
| 339 | Quiet right now — push a commit to a demo repo to see live updates. | |
| 340 | </li> | |
| 341 | ) : ( | |
| 342 | feed.map((e) => { | |
| 343 | const path = e.ref.type === "pr" ? "pulls" : "issues"; | |
| 344 | return ( | |
| 345 | <li> | |
| 346 | <span | |
| 347 | class={`demo-kind demo-kind-${e.kind.replace(/\./g, "-")}`} | |
| 348 | > | |
| 349 | {activityLabel(e.kind)} | |
| 350 | </span>{" "} | |
| 351 | <a href={`/${DEMO_USERNAME}/${e.repo}/${path}/${e.ref.number}`}> | |
| 352 | {e.repo} #{e.ref.number} | |
| 353 | </a>{" "} | |
| 354 | <span class="demo-meta">{relativeTime(e.at)}</span> | |
| 355 | </li> | |
| 356 | ); | |
| 357 | }) | |
| 358 | )} | |
| 359 | </ul> | |
| 360 | </section> | |
| 361 | </div> | |
| 362 | <script dangerouslySetInnerHTML={{ __html: pollerScript }} /> | |
| 363 | </Layout> | |
| 364 | ); | |
| 365 | }); | |
| 366 | ||
| 367 | // Minimal CSS, scoped under .demo-page so it doesn't bleed into other views. | |
| 368 | const DEMO_CSS = ` | |
| 369 | .demo-page { max-width: 1100px; margin: 0 auto; padding: 32px 20px 64px; } | |
| 370 | .demo-hero { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; justify-content: space-between; margin-bottom: 32px; } | |
| 371 | .demo-hero-inner { flex: 1 1 480px; } | |
| 372 | .demo-pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,0.7); margin-right: 8px; vertical-align: middle; animation: demo-pulse 1.6s infinite; } | |
| 373 | @keyframes demo-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } | |
| 374 | .demo-title { font-size: 36px; line-height: 1.1; margin: 12px 0 16px; } | |
| 375 | .demo-sub { color: var(--text-muted); max-width: 640px; font-size: 15px; line-height: 1.55; } | |
| 376 | .demo-sub code { background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px; font-size: 13px; } | |
| 377 | .demo-hero-cta { flex: 0 0 auto; } | |
| 378 | .demo-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 32px; } | |
| 379 | .demo-tile { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; } | |
| 380 | .demo-tile-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 12px; } | |
| 381 | .demo-list { list-style: none; margin: 0; padding: 0; font-size: 14px; line-height: 1.5; } | |
| 382 | .demo-list li { padding: 6px 0; border-bottom: 1px solid var(--border); } | |
| 383 | .demo-list li:last-child { border-bottom: 0; } | |
| 384 | .demo-list-small li { font-size: 13px; } | |
| 385 | .demo-list a { color: var(--text-strong); text-decoration: none; } | |
| 386 | .demo-list a:hover { text-decoration: underline; } | |
| 387 | .demo-meta { color: var(--text-muted); font-size: 12px; } | |
| 388 | .demo-snippet { color: var(--text-muted); font-style: italic; font-size: 12px; } | |
| 389 | .demo-empty { color: var(--text-muted); font-size: 13px; padding: 6px 0; } | |
| 390 | .demo-bigcount { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; } | |
| 391 | .demo-bigcount span:first-child { font-size: 32px; font-weight: 700; color: var(--text-strong); } | |
| 392 | .demo-bigcount-label { color: var(--text-muted); font-size: 12px; } | |
| 393 | .demo-section-title { font-size: 18px; margin: 16px 0 12px; } | |
| 394 | .demo-repos { margin-bottom: 32px; } | |
| 395 | .demo-repos-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } | |
| 396 | .demo-repo-card { display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; text-decoration: none; color: inherit; transition: border-color 0.15s; } | |
| 397 | .demo-repo-card:hover { border-color: var(--accent, #8c6dff); } | |
| 398 | .demo-repo-name { font-weight: 600; font-size: 14px; color: var(--text-strong); margin-bottom: 4px; } | |
| 399 | .demo-repo-tag { font-size: 12px; color: var(--text-muted); } | |
| 400 | .demo-feed { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; } | |
| 401 | .demo-feed-list { list-style: none; margin: 0; padding: 0; font-size: 13px; line-height: 1.6; } | |
| 402 | .demo-feed-list li { padding: 4px 0; } | |
| 403 | .demo-kind { display: inline-block; padding: 1px 7px; border-radius: 9999px; font-size: 11px; font-weight: 500; } | |
| 404 | .demo-kind-auto_merge-merged, .demo-kind-auto-merge-merged { background: rgba(52,211,153,0.12); color: #34d399; } | |
| 405 | .demo-kind-ai_build-dispatched, .demo-kind-ai-build-dispatched { background: rgba(140,109,255,0.15); color: #8c6dff; } | |
| 406 | .demo-kind-ai_review-posted, .demo-kind-ai-review-posted { background: rgba(54,197,214,0.15); color: #36c5d6; } | |
| 407 | `; | |
| 408 | ||
| 409 | export default app; |