mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 24 kB View raw
1<script setup lang="ts"> 2import { ref } from 'vue' 3import type { AccountsPayload } from '#server/api/me/accounts.get' 4import type { DashboardPayload, DashboardRepo } from '#server/api/me/dashboard.get' 5 6const installUrl = 'https://github.com/apps/synchub-to/installations/new' 7 8definePageMeta({ 9 middleware: ['authenticated'], 10}) 11 12useSeoMeta({ 13 title: 'Dashboard · synchub.to', 14 description: 'Your synchub.to mirror status.', 15}) 16 17const { data, refresh, error } = useFetch<DashboardPayload>('/api/me/dashboard', { 18 server: false, 19 lazy: true, 20}) 21const { data: accountsData, refresh: refreshAccounts } = useFetch<AccountsPayload>('/api/me/accounts', { 22 server: false, 23 lazy: true, 24}) 25 26const switcher = useTemplateRef<HTMLDetailsElement>('switcher') 27const switcherOpen = ref(false) 28 29onMounted(() => { 30 const onPointerDown = (e: PointerEvent) => { 31 if (switcherOpen.value && switcher.value && !switcher.value.contains(e.target as Node)) { 32 switcherOpen.value = false 33 } 34 } 35 document.addEventListener('pointerdown', onPointerDown) 36 onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown)) 37}) 38 39const activeAccount = computed(() => 40 accountsData.value?.accounts.find(a => a.active) ?? null, 41) 42const otherAccounts = computed(() => 43 accountsData.value?.accounts.filter(a => !a.active) ?? [], 44) 45 46function accountLabel(account: { handle: string | null, accountLogin: string | null, did: string }) { 47 if (account.handle) return `@${account.handle}` 48 if (account.accountLogin) return account.accountLogin 49 return account.did 50} 51 52async function switchTo(did: string) { 53 switcherOpen.value = false 54 pendingAction.value = `switch:${did}` 55 try { 56 await postAction('/api/me/switch', { did }) 57 await Promise.all([refresh(), refreshAccounts()]) 58 flash.value = null 59 } 60 catch { 61 flash.value = 'Error: could not switch account.' 62 } 63 finally { 64 pendingAction.value = null 65 } 66} 67 68const flash = ref<string | null>(null) 69const flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false) 70const pendingAction = ref<string | null>(null) 71 72const heading = useTemplateRef('heading') 73const router = useRouter() 74router.afterEach(() => { 75 nextTick(() => heading.value?.focus({ focusVisible: false })) 76}) 77 78function actionKey(scope: string, id: number | string) { 79 return `${scope}:${id}` 80} 81 82async function runAction(key: string, fn: () => Promise<unknown>, successMessage: string) { 83 pendingAction.value = key 84 flash.value = null 85 try { 86 await fn() 87 flash.value = successMessage 88 await refresh() 89 } 90 catch (err: unknown) { 91 const message = (err as { data?: { statusMessage?: string }, message?: string } | null)?.data?.statusMessage 92 ?? (err as Error | null)?.message 93 ?? 'something went wrong' 94 flash.value = `Error: ${message}` 95 } 96 finally { 97 pendingAction.value = null 98 } 99} 100 101// Typed Nitro routes use `:id` placeholders; passing the dynamic path to 102// `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting 103// a no-body request through native `fetch` and parsing the JSON manually 104// sidesteps that. Same-origin, so cookies are sent automatically. 105async function postAction(url: string, payload?: unknown): Promise<unknown> { 106 const response = await fetch(url, { 107 method: 'POST', 108 credentials: 'same-origin', 109 ...(payload === undefined 110 ? {} 111 : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }), 112 }) 113 if (!response.ok) { 114 let statusMessage = response.statusText 115 try { 116 const body: unknown = await response.json() 117 if (body && typeof body === 'object' && 'statusMessage' in body && typeof body.statusMessage === 'string') { 118 statusMessage = body.statusMessage 119 } 120 } 121 catch { 122 // body wasn't json; keep statusText 123 } 124 throw Object.assign(new Error(statusMessage), { statusCode: response.status }) 125 } 126 return response.json() 127} 128 129function resync(repo: DashboardRepo) { 130 return runAction( 131 actionKey('resync', repo.id), 132 () => postAction(`/api/repos/${repo.id}/resync`), 133 `queued resync for ${repo.githubFullName}.`, 134 ) 135} 136 137function disable(repo: DashboardRepo) { 138 return runAction( 139 actionKey('disable', repo.id), 140 () => postAction(`/api/repos/${repo.id}/disable`), 141 `disabled sync for ${repo.githubFullName}.`, 142 ) 143} 144 145function enable(repo: DashboardRepo) { 146 return runAction( 147 actionKey('enable', repo.id), 148 () => postAction(`/api/repos/${repo.id}/enable`), 149 `re-enabled sync for ${repo.githubFullName}.`, 150 ) 151} 152 153function rotateKey() { 154 return runAction( 155 'rotate-key', 156 () => postAction('/api/me/rotate-key'), 157 'queued SSH key rotation.', 158 ) 159} 160 161async function logout() { 162 switcherOpen.value = false 163 pendingAction.value = 'logout' 164 try { 165 const result = await postAction('/api/me/logout') as { remaining: number } 166 if (result.remaining > 0) { 167 await Promise.all([refresh(), refreshAccounts()]) 168 flash.value = null 169 pendingAction.value = null 170 } 171 else { 172 await navigateTo('/') 173 } 174 } 175 catch { 176 pendingAction.value = null 177 flash.value = 'error: could not sign out.' 178 } 179} 180 181function summariseRefs(refs: Record<string, string>): string { 182 const entries = Object.entries(refs) 183 if (entries.length === 0) return '—' 184 if (entries.length === 1) { 185 const [refName, sha] = entries[0]! 186 return `${refName} @ ${sha.slice(0, 7)}` 187 } 188 return `${entries.length} refs` 189} 190 191function fmtDate(iso: string | null): string { 192 if (!iso) return '—' 193 try { 194 return new Date(iso).toLocaleString() 195 } 196 catch { 197 return iso 198 } 199} 200</script> 201 202<template> 203 <div class="page"> 204 <a class="skip-link" href="#main">skip to main content</a> 205 <header class="nav-term"> 206 <nav class="nav-term__line" aria-label="primary"> 207 <span class="prompt" aria-hidden="true">&gt;</span> 208 <a class="nav-term__mark" href="/dashboard"><SynchubMark :wordmark="true" :size="18" /></a> 209 <details ref="switcher" class="switcher" :open="switcherOpen" @toggle="switcherOpen = ($event.target as HTMLDetailsElement).open"> 210 <summary class="switcher__summary"> 211 <span class="switcher__active">{{ activeAccount ? accountLabel(activeAccount) : 'account' }}</span> 212 <span class="switcher__caret" aria-hidden="true"></span> 213 </summary> 214 <div class="switcher__menu"> 215 <p v-if="activeAccount" class="switcher__current"> 216 <span class="switcher__handle">{{ accountLabel(activeAccount) }}</span> 217 <span v-if="activeAccount.accountLogin" class="switcher__org">{{ activeAccount.accountLogin }}</span> 218 </p> 219 <template v-if="otherAccounts.length"> 220 <p class="switcher__label">switch to</p> 221 <button 222 v-for="account in otherAccounts" 223 :key="account.did" 224 type="button" 225 class="switcher__item" 226 :disabled="pendingAction !== null" 227 @click="switchTo(account.did)" 228 > 229 <span class="switcher__handle">{{ accountLabel(account) }}</span> 230 <span v-if="account.accountLogin" class="switcher__org">{{ account.accountLogin }}</span> 231 </button> 232 </template> 233 <a class="switcher__item switcher__add" :href="installUrl">+ add account</a> 234 <button 235 type="button" 236 class="switcher__item switcher__logout" 237 :disabled="pendingAction !== null" 238 :aria-busy="pendingAction === 'logout'" 239 @click="logout" 240 > 241 {{ pendingAction === 'logout' ? 'logging out…' : 'log out' }} 242 </button> 243 </div> 244 </details> 245 </nav> 246 </header> 247 248 <div id="main" class="intro"> 249 <h1 ref="heading" tabindex="-1" class="heading-target">mirror status</h1> 250 <p class="muted"> 251 everything synchub is syncing for your connected GitHub installation. 252 </p> 253 </div> 254 255 <div v-if="error" class="card error" role="alert"> 256 <span aria-hidden="true"></span> failed to load dashboard: {{ error.message }} 257 </div> 258 259 <div v-else-if="!data" class="skeleton" aria-hidden="true"> 260 <section class="card"> 261 <h2>identity</h2> 262 <div class="sk sk--line" style="width: 60%" /> 263 <div class="sk sk--line" style="width: 45%" /> 264 </section> 265 <section class="card"> 266 <h2>SSH key</h2> 267 <div class="sk sk--line" style="width: 35%" /> 268 <div class="sk sk--block" /> 269 <div class="sk sk--btn" /> 270 </section> 271 <section class="card"> 272 <h2>repositories</h2> 273 <div v-for="n in 2" :key="n" class="sk-repo"> 274 <div class="sk-repo__main"> 275 <div class="sk sk--line" style="width: 40%" /> 276 <div class="sk sk--line sk--meta" style="width: 70%" /> 277 </div> 278 <div class="sk sk--btn" /> 279 </div> 280 </section> 281 </div> 282 283 <div v-else> 284 <section class="card"> 285 <h2>identity</h2> 286 <dl> 287 <dt>tangled</dt> 288 <dd> 289 <span v-if="data.handle">@{{ data.handle }}</span> 290 <code class="did" :class="{ 'did--secondary': data.handle }">{{ data.did }}</code> 291 </dd> 292 <template v-if="data.installation"> 293 <dt>GitHub</dt> 294 <dd> 295 {{ data.installation.accountLogin }} 296 <span class="muted">({{ data.installation.accountType.toLowerCase() }}, install #{{ data.installation.id }})</span> 297 <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span> 298 <a class="config-link" :href="`https://github.com/settings/installations/${data.installation.id}`" target="_blank" rel="noopener noreferrer">configure on GitHub &nearr;</a> 299 </dd> 300 </template> 301 <template v-else> 302 <dt>GitHub</dt> 303 <dd class="muted"> 304 no matching install row 305 </dd> 306 </template> 307 </dl> 308 </section> 309 310 <section class="card"> 311 <h2>SSH key</h2> 312 <template v-if="data.sshKey"> 313 <p class="small muted"> 314 created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>. 315 </p> 316 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre> 317 </template> 318 <p v-else class="muted"> 319 no key on file yet. it's created automatically on first sign-in. 320 </p> 321 <button 322 type="button" 323 :disabled="pendingAction !== null" 324 :aria-busy="pendingAction === 'rotate-key'" 325 @click="rotateKey" 326 > 327 {{ pendingAction === 'rotate-key' ? 'rotating' : 'rotate SSH key' }} 328 </button> 329 </section> 330 331 <section class="card"> 332 <h2>repositories ({{ data.repos.length }})</h2> 333 <p v-if="data.repos.length === 0" class="muted"> 334 no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute. 335 </p> 336 <ul v-else class="repos"> 337 <li v-for="repo in data.repos" :key="repo.id" class="repo"> 338 <div class="repo__main"> 339 <div class="repo__head"> 340 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a> 341 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 342 <span v-else class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 343 </div> 344 <dl class="repo__meta"> 345 <div class="repo__meta-item"> 346 <dt>tangled</dt> 347 <dd v-if="repo.tangledFullName" class="repo__did">{{ repo.tangledFullName }}</dd> 348 <dd v-else class="muted">not yet enrolled</dd> 349 </div> 350 <div class="repo__meta-item"> 351 <dt>last synced</dt> 352 <dd>{{ fmtDate(repo.lastSyncedAt) }}</dd> 353 </div> 354 <div class="repo__meta-item"> 355 <dt>refs</dt> 356 <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd> 357 </div> 358 </dl> 359 <p v-if="repo.lastError" class="repo__error muted small"> 360 {{ repo.lastError }} 361 </p> 362 </div> 363 <div class="repo__actions"> 364 <button 365 type="button" 366 :disabled="pendingAction !== null" 367 :aria-busy="pendingAction === actionKey('resync', repo.id)" 368 @click="resync(repo)" 369 > 370 {{ pendingAction === actionKey('resync', repo.id) ? 'resyncing' : 'resync' }} 371 </button> 372 <button 373 v-if="repo.disabledAt" 374 type="button" 375 :disabled="pendingAction !== null" 376 :aria-busy="pendingAction === actionKey('enable', repo.id)" 377 @click="enable(repo)" 378 > 379 {{ pendingAction === actionKey('enable', repo.id) ? 'enabling' : 'enable' }} 380 </button> 381 <button 382 v-else 383 type="button" 384 :disabled="pendingAction !== null" 385 :aria-busy="pendingAction === actionKey('disable', repo.id)" 386 @click="disable(repo)" 387 > 388 {{ pendingAction === actionKey('disable', repo.id) ? 'disabling' : 'disable' }} 389 </button> 390 </div> 391 </li> 392 </ul> 393 </section> 394 395 <p 396 v-if="flash" 397 class="flash" 398 :class="{ 'flash--error': flashIsError }" 399 :role="flashIsError ? 'alert' : 'status'" 400 > 401 <span aria-hidden="true">{{ flashIsError ? '' : '' }}</span> {{ flash }} 402 </p> 403 </div> 404 </div> 405</template> 406 407<style scoped> 408.page { 409 max-width: 64rem; 410 margin: 0 auto; 411 padding-inline: var(--page-gutter); 412 color: var(--color-ink); 413} 414 415.nav-term { 416 padding: var(--space-md) 0; 417 border-bottom: var(--rule-hair) solid var(--color-rule); 418} 419 420.nav-term__line { 421 display: flex; 422 align-items: center; 423 flex-wrap: wrap; 424 gap: 0.6ch; 425 font-family: var(--font-mono); 426 font-size: var(--text-sm); 427 margin: 0; 428} 429 430.nav-term__line .prompt { color: var(--color-accent); } 431 432.nav-term__mark { 433 display: inline-flex; 434 text-decoration: none; 435 margin-right: 1.2ch; 436} 437 438.switcher { 439 margin-left: auto; 440 position: relative; 441} 442 443.switcher__summary { 444 display: inline-flex; 445 align-items: center; 446 gap: 0.5ch; 447 min-height: 44px; 448 padding: 0.2rem 0.7rem; 449 border: var(--rule-hair) solid var(--color-rule-interactive); 450 border-radius: var(--radius-sm); 451 color: var(--color-muted); 452 font-family: var(--font-mono); 453 font-size: var(--text-sm); 454 white-space: nowrap; 455 cursor: pointer; 456 list-style: none; 457 transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); 458} 459 460.switcher__summary::-webkit-details-marker { display: none; } 461.switcher__summary:hover { border-color: var(--color-accent); color: var(--color-ink); } 462.switcher[open] .switcher__summary { border-color: var(--color-accent); color: var(--color-ink); } 463.switcher[open] .switcher__caret { transform: rotate(180deg); } 464 465.switcher__caret { 466 display: inline-block; 467 color: var(--color-neutral); 468 transition: transform var(--dur-micro) var(--ease-out); 469} 470 471.switcher__menu { 472 position: absolute; 473 right: 0; 474 top: calc(100% + var(--space-2xs)); 475 z-index: var(--z-overlay); 476 min-width: 13rem; 477 display: flex; 478 flex-direction: column; 479 gap: 1px; 480 padding: var(--space-2xs); 481 background: var(--color-paper-3); 482 border: var(--rule-hair) solid var(--color-rule); 483 border-radius: var(--radius-md); 484} 485 486.switcher__current { 487 display: flex; 488 flex-direction: column; 489 gap: 1px; 490 margin: 0; 491 padding: var(--space-xs) var(--space-sm); 492} 493 494.switcher__label { 495 margin: var(--space-2xs) 0 0; 496 padding: 0 var(--space-sm); 497 font-size: var(--text-xs); 498 letter-spacing: 0.06em; 499 text-transform: uppercase; 500 color: var(--color-neutral); 501} 502 503.switcher__handle { 504 font-family: var(--font-mono); 505 color: var(--color-ink); 506} 507 508.switcher__org { 509 font-size: var(--text-xs); 510 color: var(--color-neutral); 511} 512 513.switcher__item { 514 display: flex; 515 flex-direction: column; 516 gap: 1px; 517 align-items: start; 518 width: 100%; 519 min-height: 44px; 520 justify-content: center; 521 padding: var(--space-xs) var(--space-sm); 522 border: 0; 523 border-radius: var(--radius-sm); 524 background: transparent; 525 color: var(--color-muted); 526 font: inherit; 527 font-size: var(--text-sm); 528 text-align: left; 529 text-decoration: none; 530 white-space: nowrap; 531 cursor: pointer; 532 transition: background-color var(--dur-micro) var(--ease-out); 533} 534 535.switcher__item:hover:not(:disabled) { background: var(--color-paper-2); } 536.switcher__item:disabled { opacity: 0.5; cursor: progress; } 537 538.switcher__add { 539 border-top: var(--rule-hair) solid var(--color-rule); 540 border-radius: 0; 541 margin-top: var(--space-2xs); 542 color: var(--color-accent-dim); 543} 544 545.switcher__logout { color: var(--color-muted); } 546.switcher__logout:hover:not(:disabled) { color: var(--color-error); } 547 548.caret { 549 display: inline-block; 550 width: 1ch; 551 color: var(--color-accent); 552 animation: blink 1.05s steps(2) infinite; 553} 554 555@keyframes blink { 50% { opacity: 0; } } 556@media (prefers-reduced-motion: reduce) { .caret { animation: none; } } 557 558.intro { 559 padding-block: var(--space-xl) var(--space-md); 560} 561 562h1 { 563 font-size: var(--text-xl); 564 margin: 0 0 var(--space-2xs); 565} 566 567.heading-target:focus { outline: none; } 568 569h2 { 570 margin: 0 0 var(--space-md); 571 font-size: var(--text-md); 572} 573 574.muted { color: var(--color-neutral); } 575.small { font-size: var(--text-sm); } 576 577.card { 578 background: var(--color-paper-2); 579 border: var(--rule-hair) solid var(--color-rule); 580 border-radius: var(--radius-md); 581 padding: var(--space-lg); 582 margin: var(--space-md) 0; 583} 584 585.error { 586 border-color: var(--color-error); 587 color: var(--color-error); 588} 589 590/* Skeleton: dimensions mirror the loaded cards so swapping in real data 591 doesn't shift layout. */ 592.sk { 593 border-radius: var(--radius-sm); 594 background: var(--color-paper-3); 595 position: relative; 596 overflow: hidden; 597} 598 599.sk::after { 600 content: ""; 601 position: absolute; 602 inset: 0; 603 transform: translateX(-100%); 604 background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--color-paper-2) 60%, transparent), transparent); 605 animation: sk-shimmer 1.4s var(--ease-in-out) infinite; 606} 607 608@keyframes sk-shimmer { 609 to { transform: translateX(100%); } 610} 611 612.sk--line { 613 height: 1.1em; 614 margin-block: var(--space-xs); 615} 616 617.sk--meta { 618 height: 0.85em; 619} 620 621.sk--block { 622 height: 3.2em; 623 margin-block: var(--space-sm); 624} 625 626.sk--btn { 627 height: 2.1em; 628 width: 8rem; 629} 630 631.sk-repo { 632 display: flex; 633 align-items: start; 634 justify-content: space-between; 635 gap: var(--space-lg); 636 padding-block: var(--space-md); 637 border-top: var(--rule-hair) solid var(--color-rule); 638} 639 640.sk-repo:first-of-type { border-top: 0; } 641.sk-repo__main { flex: 1; min-width: 0; } 642.sk-repo .sk--btn { flex: none; width: 5.5rem; } 643 644@media (prefers-reduced-motion: reduce) { 645 .sk::after { animation: none; } 646} 647 648dl { 649 display: grid; 650 grid-template-columns: max-content minmax(0, 1fr); 651 align-items: baseline; 652 gap: var(--space-2xs) var(--space-md); 653 margin: 0; 654} 655 656.did { 657 word-break: break-all; 658} 659 660.did--secondary { 661 display: block; 662 margin-top: 2px; 663 font-size: var(--text-xs); 664 color: var(--color-neutral); 665} 666 667dt { 668 font-family: var(--font-mono); 669 font-size: var(--text-sm); 670 color: var(--color-neutral); 671} 672 673dd { 674 margin: 0; 675 overflow-wrap: anywhere; 676 min-width: 0; 677} 678 679.config-link { 680 display: inline-block; 681 margin-left: var(--space-xs); 682 font-family: var(--font-mono); 683 font-size: var(--text-xs); 684 white-space: nowrap; 685} 686 687code { 688 font-family: var(--font-mono); 689 font-size: var(--text-sm); 690 color: var(--color-ink); 691} 692 693.pubkey { 694 font-family: var(--font-mono); 695 font-size: var(--text-xs); 696 background: var(--color-paper); 697 border: var(--rule-hair) solid var(--color-rule); 698 padding: var(--space-sm); 699 border-radius: var(--radius-sm); 700 overflow-x: auto; 701 white-space: pre-wrap; 702 word-break: break-all; 703 margin: var(--space-xs) 0 var(--space-md); 704 color: var(--color-muted); 705} 706 707.repos { 708 list-style: none; 709 margin: 0; 710 padding: 0; 711} 712 713.repo { 714 display: flex; 715 align-items: start; 716 justify-content: space-between; 717 gap: var(--space-lg); 718 padding-block: var(--space-md); 719 border-top: var(--rule-hair) solid var(--color-rule); 720} 721 722.repo:first-child { border-top: 0; } 723 724.repo__main { min-width: 0; flex: 1; } 725 726.repo__head { 727 display: flex; 728 align-items: center; 729 flex-wrap: wrap; 730 gap: var(--space-xs); 731 margin-bottom: var(--space-xs); 732} 733 734.repo__name { 735 font-family: var(--font-mono); 736 font-size: var(--text-base); 737 color: var(--color-ink); 738 text-decoration: none; 739 border-bottom: var(--rule-hair) solid var(--color-rule); 740 overflow-wrap: anywhere; 741} 742 743.repo__name:hover { color: var(--color-accent); border-color: var(--color-accent); } 744 745.repo__meta { 746 display: flex; 747 flex-wrap: wrap; 748 gap: var(--space-xs) var(--space-lg); 749 margin: 0; 750} 751 752.repo__meta-item { 753 display: flex; 754 flex-direction: column; 755 gap: 1px; 756 min-width: 0; 757} 758 759.repo__meta dt { 760 font-family: var(--font-mono); 761 font-size: var(--text-xs); 762 letter-spacing: 0.06em; 763 text-transform: uppercase; 764 color: var(--color-neutral); 765} 766 767.repo__meta dd { 768 margin: 0; 769 font-size: var(--text-sm); 770 color: var(--color-muted); 771} 772 773.repo__did { 774 font-family: var(--font-mono); 775 word-break: break-all; 776} 777 778.repo__error { 779 margin: var(--space-xs) 0 0; 780 word-break: break-word; 781} 782 783.repo__actions { 784 display: flex; 785 flex-direction: column; 786 gap: var(--space-2xs); 787 flex: none; 788} 789 790.badge { 791 display: inline-block; 792 padding: 0.1rem 0.5rem; 793 border-radius: var(--radius-sm); 794 font-family: var(--font-mono); 795 font-size: var(--text-xs); 796 border: var(--rule-hair) solid var(--color-rule-interactive); 797 color: var(--color-muted); 798 margin-right: var(--space-2xs); 799} 800 801.badge::before { 802 margin-right: 0.4ch; 803} 804 805.badge-active { border-color: var(--color-ok); color: var(--color-ok); } 806.badge-active::before { content: "●"; } 807.badge-pending, 808.badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } 809.badge-pending::before, 810.badge-enrolling::before { content: "◐"; } 811.badge-error { border-color: var(--color-error); color: var(--color-error); } 812.badge-error::before { content: "⚠"; } 813.badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); } 814.badge-disabled::before { content: "⏸"; } 815 816button { 817 font-family: var(--font-mono); 818 font-size: var(--text-sm); 819 padding: 0.3rem 0.75rem; 820 border-radius: var(--radius-sm); 821 border: var(--rule-hair) solid var(--color-rule-interactive); 822 background: var(--color-paper-3); 823 color: var(--color-ink); 824 cursor: pointer; 825 white-space: nowrap; 826 transition: border-color var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out); 827} 828 829button:hover:not(:disabled) { border-color: var(--color-accent); transform: translateY(-1px); } 830button:active:not(:disabled) { transform: translateY(0); } 831 832button:disabled { 833 opacity: 0.5; 834 cursor: progress; 835} 836 837@media (max-width: 40rem) { 838 .repo { 839 flex-direction: column; 840 gap: var(--space-sm); 841 } 842 .repo__actions { 843 flex-direction: row; 844 flex-wrap: wrap; 845 } 846} 847 848.flash { 849 position: fixed; 850 right: var(--space-md); 851 bottom: var(--space-md); 852 z-index: var(--z-toast); 853 padding: var(--space-xs) var(--space-sm); 854 background: var(--color-paper-3); 855 border: var(--rule-hair) solid var(--color-accent); 856 color: var(--color-ink); 857 border-radius: var(--radius-sm); 858 font-family: var(--font-mono); 859 font-size: var(--text-sm); 860} 861 862.flash--error { border-color: var(--color-error); } 863</style>