mirror your GitHub repos to tangled.org automatically
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">></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 ↗</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>