mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

feat: frontend design

+886 -235
+102
app/assets/css/base.css
··· 1 + *, *::before, *::after { box-sizing: border-box; } 2 + 3 + html, body { 4 + overflow-x: clip; 5 + } 6 + 7 + html { 8 + background: var(--color-paper); 9 + color: var(--color-ink); 10 + font-family: var(--font-body); 11 + font-size: 100%; 12 + line-height: 1.6; 13 + -webkit-font-smoothing: antialiased; 14 + text-rendering: optimizeLegibility; 15 + } 16 + 17 + body { 18 + margin: 0; 19 + font-weight: 400; 20 + } 21 + 22 + h1, h2, h3, h4 { 23 + font-family: var(--font-display); 24 + font-weight: 700; 25 + line-height: 1.1; 26 + letter-spacing: -0.02em; 27 + margin: 0; 28 + } 29 + 30 + a { 31 + color: var(--color-accent); 32 + text-decoration-thickness: 1px; 33 + text-underline-offset: 2px; 34 + } 35 + 36 + a:hover { 37 + color: var(--color-ink); 38 + } 39 + 40 + code, pre, kbd { 41 + font-family: var(--font-mono); 42 + font-variant-ligatures: none; 43 + } 44 + 45 + :focus-visible { 46 + outline: 2px solid var(--color-focus); 47 + outline-offset: 2px; 48 + } 49 + 50 + .skip-link { 51 + position: absolute; 52 + left: var(--space-md); 53 + top: var(--space-md); 54 + z-index: var(--z-toast); 55 + padding: 0.5rem 0.9rem; 56 + background: var(--color-paper-3); 57 + border: var(--rule-hair) solid var(--color-accent); 58 + border-radius: var(--radius-sm); 59 + color: var(--color-ink); 60 + font-family: var(--font-mono); 61 + font-size: var(--text-sm); 62 + text-decoration: none; 63 + transform: translateY(-150%); 64 + } 65 + 66 + .skip-link:focus { 67 + transform: none; 68 + } 69 + 70 + button { 71 + font: inherit; 72 + } 73 + 74 + ::selection { 75 + background: var(--color-accent); 76 + color: var(--color-paper); 77 + } 78 + 79 + .reveal { 80 + opacity: 0; 81 + transform: translateY(8px); 82 + animation: reveal var(--dur-long) var(--ease-out) forwards; 83 + animation-delay: calc(var(--i, 0) * 60ms); 84 + } 85 + 86 + @keyframes reveal { 87 + to { opacity: 1; transform: none; } 88 + } 89 + 90 + @media (prefers-reduced-motion: reduce) { 91 + *, *::before, *::after { 92 + animation-duration: 150ms !important; 93 + animation-iteration-count: 1 !important; 94 + transition-duration: 150ms !important; 95 + } 96 + .reveal { 97 + animation: reveal-reduced 150ms linear forwards; 98 + } 99 + @keyframes reveal-reduced { 100 + to { opacity: 1; transform: none; } 101 + } 102 + }
+61
app/assets/css/tokens.css
··· 1 + :root { 2 + --color-paper: oklch(15% 0.012 250); 3 + --color-paper-2: oklch(18% 0.014 250); 4 + --color-paper-3: oklch(22% 0.016 250); 5 + --color-rule: oklch(30% 0.012 250); 6 + --color-rule-interactive: oklch(52% 0.014 250); 7 + --color-neutral: oklch(64% 0.010 250); 8 + --color-muted: oklch(72% 0.008 250); 9 + --color-ink: oklch(94% 0.006 145); 10 + 11 + --color-accent: oklch(82% 0.19 145); 12 + --color-accent-dim: oklch(62% 0.14 145); 13 + --color-focus: oklch(82% 0.19 145); 14 + 15 + --color-ok: oklch(82% 0.18 145); 16 + --color-warn: oklch(82% 0.15 85); 17 + --color-error: oklch(72% 0.17 25); 18 + --color-info: oklch(78% 0.12 240); 19 + 20 + --font-display: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; 21 + --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; 22 + --font-body: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; 23 + 24 + --text-xs: 0.75rem; 25 + --text-sm: 0.85rem; 26 + --text-base: 1rem; 27 + --text-md: 1.2rem; 28 + --text-lg: 1.5rem; 29 + --text-xl: 1.95rem; 30 + --text-2xl: 2.45rem; 31 + --text-display: clamp(2.5rem, 6vw + 1rem, 4.75rem); 32 + --text-display-s: clamp(1.9rem, 4vw + 1rem, 3rem); 33 + 34 + --space-2xs: 0.25rem; 35 + --space-xs: 0.5rem; 36 + --space-sm: 0.75rem; 37 + --space-md: 1rem; 38 + --space-lg: 1.5rem; 39 + --space-xl: 2.5rem; 40 + --space-2xl: 4rem; 41 + --space-3xl: 6rem; 42 + 43 + --page-gutter: clamp(1rem, 4vw, 1.5rem); 44 + --measure: 60ch; 45 + 46 + --rule-hair: 1px; 47 + --radius-sm: 3px; 48 + --radius-md: 5px; 49 + 50 + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); 51 + --ease-in: cubic-bezier(0.7, 0, 0.84, 0); 52 + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); 53 + --dur-micro: 120ms; 54 + --dur-short: 220ms; 55 + --dur-long: 420ms; 56 + 57 + --z-base: 1; 58 + --z-sticky: 10; 59 + --z-overlay: 100; 60 + --z-toast: 1000; 61 + }
+74
app/components/SynchubMark.vue
··· 1 + <script setup lang="ts"> 2 + withDefaults(defineProps<{ 3 + wordmark?: boolean 4 + size?: number 5 + }>(), { 6 + wordmark: true, 7 + size: 22, 8 + }) 9 + </script> 10 + 11 + <template> 12 + <span class="mark" :class="{ 'mark--icon-only': !wordmark }"> 13 + <svg 14 + class="mark__glyph" 15 + :width="size" 16 + :height="size" 17 + viewBox="0 0 32 32" 18 + fill="none" 19 + role="img" 20 + aria-label="synchub" 21 + > 22 + <!-- source node --> 23 + <circle cx="7" cy="7" r="3.5" stroke="currentColor" stroke-width="2" /> 24 + <!-- mirror node --> 25 + <circle cx="25" cy="25" r="3.5" stroke="currentColor" stroke-width="2" /> 26 + <!-- directional tie: a git-ref path from source to mirror --> 27 + <path 28 + d="M7 11.5 V20 a4 4 0 0 0 4 4 H20.5" 29 + stroke="currentColor" 30 + stroke-width="2" 31 + stroke-linecap="round" 32 + /> 33 + <!-- arrowhead into the mirror node --> 34 + <path 35 + d="M17 20.5 L21 24.5 L17 28.5" 36 + stroke="var(--mark-accent, currentColor)" 37 + stroke-width="2" 38 + stroke-linecap="round" 39 + stroke-linejoin="round" 40 + /> 41 + </svg> 42 + <span v-if="wordmark" class="mark__word">synchub<span class="mark__tld">.to</span></span> 43 + </span> 44 + </template> 45 + 46 + <style scoped> 47 + .mark { 48 + display: inline-flex; 49 + align-items: center; 50 + gap: 0.5ch; 51 + color: var(--color-ink); 52 + } 53 + 54 + .mark__glyph { 55 + flex: none; 56 + --mark-accent: var(--color-accent); 57 + } 58 + 59 + .mark__word { 60 + font-family: var(--font-display); 61 + font-weight: 700; 62 + font-size: 1.05rem; 63 + letter-spacing: -0.03em; 64 + white-space: nowrap; 65 + } 66 + 67 + .mark__tld { 68 + color: var(--color-accent); 69 + } 70 + 71 + .mark--icon-only { 72 + gap: 0; 73 + } 74 + </style>
+339 -155
app/pages/dashboard.vue
··· 14 14 const { data, refresh, error } = await useFetch<DashboardPayload>('/api/me/dashboard') 15 15 16 16 const flash = ref<string | null>(null) 17 + const flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false) 17 18 const pendingAction = ref<string | null>(null) 19 + 20 + const heading = useTemplateRef('heading') 21 + const router = useRouter() 22 + router.afterEach(() => { 23 + nextTick(() => heading.value?.focus({ focusVisible: false })) 24 + }) 18 25 19 26 function actionKey(scope: string, id: number | string) { 20 27 return `${scope}:${id}` ··· 93 100 ) 94 101 } 95 102 103 + async function logout() { 104 + pendingAction.value = 'logout' 105 + try { 106 + await postAction('/api/me/logout') 107 + await navigateTo('/') 108 + } 109 + catch { 110 + pendingAction.value = null 111 + flash.value = 'Error: could not sign out.' 112 + } 113 + } 114 + 96 115 function summariseRefs(refs: Record<string, string>): string { 97 116 const entries = Object.entries(refs) 98 117 if (entries.length === 0) return '—' ··· 116 135 117 136 <template> 118 137 <div class="page"> 119 - <header> 120 - <h1>synchub.to</h1> 138 + <a class="skip-link" href="#main">skip to main content</a> 139 + <header class="nav-term"> 140 + <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">&gt;</span> <a class="nav-term__mark" href="/dashboard" aria-label="synchub.to dashboard"><SynchubMark :wordmark="true" :size="18" /></a> <button type="button" class="nav-term__logout" :disabled="pendingAction !== null" :aria-busy="pendingAction === 'logout'" @click="logout">{{ pendingAction === 'logout' ? 'logging out…' : '--logout' }}</button></nav> 141 + </header> 142 + 143 + <div id="main" class="intro"> 144 + <h1 ref="heading" tabindex="-1" class="heading-target">mirror status</h1> 121 145 <p class="muted"> 122 - Mirror status for your connected GitHub installation. 146 + everything synchub is syncing for your connected GitHub installation. 123 147 </p> 124 - </header> 148 + </div> 125 149 126 - <div v-if="error" class="card error"> 127 - Failed to load dashboard: {{ error.message }} 150 + <div v-if="error" class="card error" role="alert"> 151 + <span aria-hidden="true">⚠</span> failed to load dashboard: {{ error.message }} 128 152 </div> 129 153 130 154 <div v-else-if="data"> 131 155 <section class="card"> 132 - <h2>Identity</h2> 156 + <h2>identity</h2> 133 157 <dl> 134 158 <dt>tangled DID</dt> 135 159 <dd><code>{{ data.did }}</code></dd> ··· 153 177 </template> 154 178 </dl> 155 179 <p class="muted small"> 156 - Connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>. 157 - <a href="https://github.com/apps/synchub-to/installations/new">Connect a different installation?</a> 180 + connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>. 181 + <a href="https://github.com/apps/synchub-to/installations/new">connect a different installation?</a> 158 182 </p> 159 183 </section> 160 184 ··· 162 186 <h2>SSH key</h2> 163 187 <template v-if="data.sshKey"> 164 188 <p class="small muted"> 165 - Created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>. 189 + created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>. 166 190 </p> 167 191 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre> 168 192 </template> 169 193 <p v-else class="muted"> 170 - No key on file yet. It's created automatically on first sign-in. 194 + no key on file yet. it's created automatically on first sign-in. 171 195 </p> 172 196 <button 173 197 type="button" 174 198 :disabled="pendingAction !== null" 199 + :aria-busy="pendingAction === 'rotate-key'" 175 200 @click="rotateKey" 176 201 > 177 202 {{ pendingAction === 'rotate-key' ? 'Rotating…' : 'Rotate SSH key' }} ··· 179 204 </section> 180 205 181 206 <section class="card"> 182 - <h2>Repositories ({{ data.repos.length }})</h2> 207 + <h2>repositories ({{ data.repos.length }})</h2> 183 208 <p v-if="data.repos.length === 0" class="muted"> 184 - No repositories enrolled yet. New installs are backfilled in the background; refresh in a minute. 209 + no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute. 185 210 </p> 186 - <table v-else> 187 - <thead> 188 - <tr> 189 - <th>GitHub</th> 190 - <th>Tangled</th> 191 - <th>Status</th> 192 - <th>Last synced</th> 193 - <th>Refs</th> 194 - <th>Actions</th> 195 - </tr> 196 - </thead> 197 - <tbody> 198 - <tr v-for="repo in data.repos" :key="repo.id"> 199 - <td> 200 - <a :href="`https://github.com/${repo.githubFullName}`" rel="noopener">{{ repo.githubFullName }}</a> 201 - </td> 202 - <td> 203 - <span v-if="repo.tangledFullName" class="small">{{ repo.tangledFullName }}</span> 204 - <span v-else class="muted small">—</span> 205 - </td> 206 - <td> 211 + <ul v-else class="repos"> 212 + <li v-for="repo in data.repos" :key="repo.id" class="repo"> 213 + <div class="repo__main"> 214 + <div class="repo__head"> 215 + <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a> 207 216 <span class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 208 217 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 209 - <div v-if="repo.lastError" class="muted small"> 210 - {{ repo.lastError }} 218 + </div> 219 + <dl class="repo__meta"> 220 + <div class="repo__meta-item"> 221 + <dt>tangled</dt> 222 + <dd v-if="repo.tangledFullName" class="repo__did">{{ repo.tangledFullName }}</dd> 223 + <dd v-else class="muted">not yet enrolled</dd> 211 224 </div> 212 - </td> 213 - <td class="small"> 214 - {{ fmtDate(repo.lastSyncedAt) }} 215 - </td> 216 - <td class="small"> 217 - {{ summariseRefs(repo.lastSyncedRefs) }} 218 - </td> 219 - <td> 220 - <div class="actions"> 221 - <button 222 - type="button" 223 - :disabled="pendingAction !== null" 224 - @click="resync(repo)" 225 - > 226 - {{ pendingAction === actionKey('resync', repo.id) ? '…' : 'Resync' }} 227 - </button> 228 - <button 229 - v-if="repo.disabledAt" 230 - type="button" 231 - :disabled="pendingAction !== null" 232 - @click="enable(repo)" 233 - > 234 - {{ pendingAction === actionKey('enable', repo.id) ? '…' : 'Enable' }} 235 - </button> 236 - <button 237 - v-else 238 - type="button" 239 - :disabled="pendingAction !== null" 240 - @click="disable(repo)" 241 - > 242 - {{ pendingAction === actionKey('disable', repo.id) ? '…' : 'Disable' }} 243 - </button> 225 + <div class="repo__meta-item"> 226 + <dt>last synced</dt> 227 + <dd>{{ fmtDate(repo.lastSyncedAt) }}</dd> 228 + </div> 229 + <div class="repo__meta-item"> 230 + <dt>refs</dt> 231 + <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd> 244 232 </div> 245 - </td> 246 - </tr> 247 - </tbody> 248 - </table> 233 + </dl> 234 + <p v-if="repo.lastError" class="repo__error muted small"> 235 + {{ repo.lastError }} 236 + </p> 237 + </div> 238 + <div class="repo__actions"> 239 + <button 240 + type="button" 241 + :disabled="pendingAction !== null" 242 + :aria-busy="pendingAction === actionKey('resync', repo.id)" 243 + @click="resync(repo)" 244 + > 245 + {{ pendingAction === actionKey('resync', repo.id) ? 'Resyncing…' : 'Resync' }} 246 + </button> 247 + <button 248 + v-if="repo.disabledAt" 249 + type="button" 250 + :disabled="pendingAction !== null" 251 + :aria-busy="pendingAction === actionKey('enable', repo.id)" 252 + @click="enable(repo)" 253 + > 254 + {{ pendingAction === actionKey('enable', repo.id) ? 'Enabling…' : 'Enable' }} 255 + </button> 256 + <button 257 + v-else 258 + type="button" 259 + :disabled="pendingAction !== null" 260 + :aria-busy="pendingAction === actionKey('disable', repo.id)" 261 + @click="disable(repo)" 262 + > 263 + {{ pendingAction === actionKey('disable', repo.id) ? 'Disabling…' : 'Disable' }} 264 + </button> 265 + </div> 266 + </li> 267 + </ul> 249 268 </section> 250 269 251 - <p v-if="flash" class="flash" role="status"> 252 - {{ flash }} 270 + <p 271 + v-if="flash" 272 + class="flash" 273 + :class="{ 'flash--error': flashIsError }" 274 + :role="flashIsError ? 'alert' : 'status'" 275 + > 276 + <span aria-hidden="true">{{ flashIsError ? '⚠' : '✓' }}</span> {{ flash }} 253 277 </p> 254 278 </div> 255 279 </div> 256 280 </template> 257 281 258 282 <style scoped> 259 - :root { 260 - --bg: #fafafa; 261 - --fg: #1a1a1a; 262 - --muted: #6b6b6b; 263 - --border: #e2e2e2; 264 - --accent: #2563eb; 265 - --error: #b91c1c; 266 - --warn: #b45309; 267 - --ok: #15803d; 268 - } 269 - 270 283 .page { 271 284 max-width: 64rem; 272 285 margin: 0 auto; 273 - padding: 2rem 1rem; 274 - font-family: system-ui, sans-serif; 275 - color: var(--fg); 286 + padding-inline: var(--page-gutter); 287 + color: var(--color-ink); 288 + } 289 + 290 + .nav-term { 291 + padding: var(--space-md) 0; 292 + border-bottom: var(--rule-hair) solid var(--color-rule); 293 + } 294 + 295 + .nav-term__line { 296 + display: flex; 297 + align-items: center; 298 + flex-wrap: wrap; 299 + gap: 0.6ch; 300 + font-family: var(--font-mono); 301 + font-size: var(--text-sm); 302 + margin: 0; 276 303 } 277 304 278 - h1 { 279 - margin: 0 0 0.25rem; 305 + .nav-term__line .prompt { color: var(--color-accent); } 306 + 307 + .nav-term__mark { 308 + display: inline-flex; 309 + text-decoration: none; 310 + margin-right: 1.2ch; 280 311 } 281 312 282 - h2 { 283 - margin: 0 0 1rem; 284 - font-size: 1.125rem; 313 + .nav-term__args { 314 + display: inline-flex; 315 + align-items: center; 316 + gap: 0.9ch; 317 + list-style: none; 318 + margin: 0; 319 + padding: 0; 285 320 } 286 321 287 - .muted { 288 - color: var(--muted); 322 + .nav-term__args a { 323 + display: inline-flex; 324 + align-items: center; 325 + min-height: 44px; 326 + color: var(--color-muted); 327 + text-decoration: underline; 328 + text-underline-offset: 2px; 329 + white-space: nowrap; 289 330 } 290 331 291 - .small { 292 - font-size: 0.875rem; 332 + .nav-term__args a:hover { color: var(--color-ink); } 333 + 334 + .nav-term__logout { 335 + margin-left: auto; 336 + padding: 0.2rem 0.7rem; 337 + border: var(--rule-hair) solid var(--color-rule-interactive); 338 + border-radius: var(--radius-sm); 339 + background: transparent; 340 + color: var(--color-muted); 341 + font-family: var(--font-mono); 342 + font-size: var(--text-sm); 343 + white-space: nowrap; 344 + cursor: pointer; 345 + transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); 293 346 } 294 347 348 + .nav-term__logout:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-ink); } 349 + .nav-term__logout:disabled { opacity: 0.5; cursor: progress; } 350 + 351 + .caret { 352 + display: inline-block; 353 + width: 1ch; 354 + color: var(--color-accent); 355 + animation: blink 1.05s steps(2) infinite; 356 + } 357 + 358 + @keyframes blink { 50% { opacity: 0; } } 359 + @media (prefers-reduced-motion: reduce) { .caret { animation: none; } } 360 + 361 + .intro { 362 + padding-block: var(--space-xl) var(--space-md); 363 + } 364 + 365 + h1 { 366 + font-size: var(--text-xl); 367 + margin: 0 0 var(--space-2xs); 368 + } 369 + 370 + .heading-target:focus { outline: none; } 371 + 372 + h2 { 373 + margin: 0 0 var(--space-md); 374 + font-size: var(--text-md); 375 + } 376 + 377 + .muted { color: var(--color-neutral); } 378 + .small { font-size: var(--text-sm); } 379 + 295 380 .card { 296 - background: #fff; 297 - border: 1px solid var(--border); 298 - border-radius: 0.5rem; 299 - padding: 1.25rem; 300 - margin: 1rem 0; 381 + background: var(--color-paper-2); 382 + border: var(--rule-hair) solid var(--color-rule); 383 + border-radius: var(--radius-md); 384 + padding: var(--space-lg); 385 + margin: var(--space-md) 0; 301 386 } 302 387 303 388 .error { 304 - border-color: var(--error); 305 - color: var(--error); 389 + border-color: var(--color-error); 390 + color: var(--color-error); 306 391 } 307 392 308 393 dl { 309 394 display: grid; 310 - grid-template-columns: max-content 1fr; 311 - gap: 0.25rem 1rem; 312 - margin: 0 0 0.75rem; 395 + grid-template-columns: max-content minmax(0, 1fr); 396 + gap: var(--space-2xs) var(--space-md); 397 + margin: 0 0 var(--space-sm); 313 398 } 314 399 315 400 dt { 316 - font-weight: 600; 401 + font-family: var(--font-mono); 402 + font-size: var(--text-sm); 403 + color: var(--color-neutral); 317 404 } 318 405 319 406 dd { 320 407 margin: 0; 408 + overflow-wrap: anywhere; 409 + min-width: 0; 321 410 } 322 411 323 412 code { 324 - font-family: ui-monospace, monospace; 325 - font-size: 0.875rem; 413 + font-family: var(--font-mono); 414 + font-size: var(--text-sm); 415 + color: var(--color-ink); 326 416 } 327 417 328 418 .pubkey { 329 - font-family: ui-monospace, monospace; 330 - font-size: 0.75rem; 331 - background: #f3f3f3; 332 - padding: 0.5rem; 333 - border-radius: 0.25rem; 419 + font-family: var(--font-mono); 420 + font-size: var(--text-xs); 421 + background: var(--color-paper); 422 + border: var(--rule-hair) solid var(--color-rule); 423 + padding: var(--space-sm); 424 + border-radius: var(--radius-sm); 334 425 overflow-x: auto; 335 426 white-space: pre-wrap; 336 427 word-break: break-all; 337 - margin: 0.5rem 0 1rem; 428 + margin: var(--space-xs) 0 var(--space-md); 429 + color: var(--color-muted); 338 430 } 339 431 340 - table { 341 - width: 100%; 342 - border-collapse: collapse; 343 - font-size: 0.875rem; 432 + .repos { 433 + list-style: none; 434 + margin: 0; 435 + padding: 0; 344 436 } 345 437 346 - th, td { 347 - text-align: left; 348 - padding: 0.5rem 0.5rem; 349 - border-bottom: 1px solid var(--border); 350 - vertical-align: top; 438 + .repo { 439 + display: flex; 440 + align-items: start; 441 + justify-content: space-between; 442 + gap: var(--space-lg); 443 + padding-block: var(--space-md); 444 + border-top: var(--rule-hair) solid var(--color-rule); 351 445 } 352 446 353 - th { 354 - font-weight: 600; 355 - background: #f3f3f3; 447 + .repo:first-child { border-top: 0; } 448 + 449 + .repo__main { min-width: 0; flex: 1; } 450 + 451 + .repo__head { 452 + display: flex; 453 + align-items: center; 454 + flex-wrap: wrap; 455 + gap: var(--space-xs); 456 + margin-bottom: var(--space-xs); 356 457 } 357 458 358 - .badge { 359 - display: inline-block; 360 - padding: 0.125rem 0.5rem; 361 - border-radius: 999px; 362 - font-size: 0.75rem; 363 - background: #eee; 364 - color: #333; 365 - margin-right: 0.25rem; 459 + .repo__name { 460 + font-family: var(--font-mono); 461 + font-size: var(--text-base); 462 + color: var(--color-ink); 463 + text-decoration: none; 464 + border-bottom: var(--rule-hair) solid var(--color-rule); 465 + overflow-wrap: anywhere; 366 466 } 367 467 368 - .badge-active { background: #dcfce7; color: var(--ok); } 369 - .badge-pending { background: #fef3c7; color: var(--warn); } 370 - .badge-enrolling { background: #fef3c7; color: var(--warn); } 371 - .badge-error { background: #fee2e2; color: var(--error); } 372 - .badge-disabled { background: #e0e7ff; color: var(--accent); } 468 + .repo__name:hover { color: var(--color-accent); border-color: var(--color-accent); } 373 469 374 - .actions { 470 + .repo__meta { 375 471 display: flex; 376 - gap: 0.25rem; 377 472 flex-wrap: wrap; 473 + gap: var(--space-xs) var(--space-lg); 474 + margin: 0; 378 475 } 379 476 477 + .repo__meta-item { 478 + display: flex; 479 + flex-direction: column; 480 + gap: 1px; 481 + min-width: 0; 482 + } 483 + 484 + .repo__meta dt { 485 + font-family: var(--font-mono); 486 + font-size: var(--text-xs); 487 + letter-spacing: 0.06em; 488 + text-transform: uppercase; 489 + color: var(--color-neutral); 490 + } 491 + 492 + .repo__meta dd { 493 + margin: 0; 494 + font-size: var(--text-sm); 495 + color: var(--color-muted); 496 + } 497 + 498 + .repo__did { 499 + font-family: var(--font-mono); 500 + word-break: break-all; 501 + } 502 + 503 + .repo__error { 504 + margin: var(--space-xs) 0 0; 505 + word-break: break-word; 506 + } 507 + 508 + .repo__actions { 509 + display: flex; 510 + flex-direction: column; 511 + gap: var(--space-2xs); 512 + flex: none; 513 + } 514 + 515 + .badge { 516 + display: inline-block; 517 + padding: 0.1rem 0.5rem; 518 + border-radius: var(--radius-sm); 519 + font-family: var(--font-mono); 520 + font-size: var(--text-xs); 521 + border: var(--rule-hair) solid var(--color-rule-interactive); 522 + color: var(--color-muted); 523 + margin-right: var(--space-2xs); 524 + } 525 + 526 + .badge::before { 527 + margin-right: 0.4ch; 528 + } 529 + 530 + .badge-active { border-color: var(--color-ok); color: var(--color-ok); } 531 + .badge-active::before { content: "●"; } 532 + .badge-pending, 533 + .badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } 534 + .badge-pending::before, 535 + .badge-enrolling::before { content: "◐"; } 536 + .badge-error { border-color: var(--color-error); color: var(--color-error); } 537 + .badge-error::before { content: "⚠"; } 538 + .badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); } 539 + .badge-disabled::before { content: "⏸"; } 540 + 380 541 button { 381 - font: inherit; 382 - padding: 0.25rem 0.75rem; 383 - border-radius: 0.25rem; 384 - border: 1px solid var(--border); 385 - background: #fff; 542 + font-family: var(--font-mono); 543 + font-size: var(--text-sm); 544 + padding: 0.3rem 0.75rem; 545 + border-radius: var(--radius-sm); 546 + border: var(--rule-hair) solid var(--color-rule-interactive); 547 + background: var(--color-paper-3); 548 + color: var(--color-ink); 386 549 cursor: pointer; 550 + white-space: nowrap; 551 + transition: border-color var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out); 387 552 } 553 + 554 + button:hover:not(:disabled) { border-color: var(--color-accent); transform: translateY(-1px); } 555 + button:active:not(:disabled) { transform: translateY(0); } 388 556 389 557 button:disabled { 390 558 opacity: 0.5; 391 559 cursor: progress; 392 560 } 393 561 562 + @media (max-width: 40rem) { 563 + .repo { 564 + flex-direction: column; 565 + gap: var(--space-sm); 566 + } 567 + .repo__actions { 568 + flex-direction: row; 569 + flex-wrap: wrap; 570 + } 571 + } 572 + 394 573 .flash { 395 574 position: fixed; 396 - right: 1rem; 397 - bottom: 1rem; 398 - padding: 0.5rem 0.75rem; 399 - background: #111; 400 - color: #fff; 401 - border-radius: 0.25rem; 402 - font-size: 0.875rem; 575 + right: var(--space-md); 576 + bottom: var(--space-md); 577 + z-index: var(--z-toast); 578 + padding: var(--space-xs) var(--space-sm); 579 + background: var(--color-paper-3); 580 + border: var(--rule-hair) solid var(--color-accent); 581 + color: var(--color-ink); 582 + border-radius: var(--radius-sm); 583 + font-family: var(--font-mono); 584 + font-size: var(--text-sm); 403 585 } 586 + 587 + .flash--error { border-color: var(--color-error); } 404 588 </style>
+286 -80
app/pages/index.vue
··· 10 10 } 11 11 12 12 const installUrl = 'https://github.com/apps/synchub-to/installations/new' 13 + 14 + const heading = useTemplateRef('heading') 15 + const router = useRouter() 16 + router.afterEach(() => { 17 + nextTick(() => heading.value?.focus({ focusVisible: false })) 18 + }) 13 19 </script> 14 20 15 21 <template> 16 - <main> 17 - <h1>synchub.to</h1> 18 - <p>Mirror your GitHub repos to tangled.org, automatically.</p> 22 + <div class="shell"> 23 + <a class="skip-link" href="#main">skip to main content</a> 24 + <header class="nav-term"> 25 + <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">&gt;</span> <a class="nav-term__mark" href="/" aria-label="synchub.to home"><SynchubMark :wordmark="true" :size="18" /></a> <ul class="nav-term__args"><li><a href="#start">--start</a></li> <li><a href="#signin">--sign-in</a></li></ul><span class="caret" aria-hidden="true">█</span></nav> 26 + </header> 19 27 20 - <section class="signin"> 21 - <h2>Sign in</h2> 22 - <p class="muted"> 23 - Already installed the GitHub App and connected your tangled 24 - identity? Enter your handle to sign in on this device. 25 - </p> 26 - <form action="/api/atproto/login" method="get"> 27 - <label> 28 - Handle 29 - <input 30 - type="text" 31 - name="handle" 32 - required 33 - placeholder="alice.bsky.social" 34 - autocomplete="username" 35 - autocapitalize="none" 36 - autocorrect="off" 37 - spellcheck="false" 38 - > 39 - </label> 40 - <button type="submit">Sign in</button> 41 - </form> 42 - </section> 28 + <main id="main"> 29 + <section class="hero reveal" style="--i: 0"> 30 + <h1 ref="heading" tabindex="-1" class="hero__title heading-target">github ⇉ tangled</h1> 31 + <p class="hero__lede"> 32 + synchub mirrors your public repositories to 33 + <a href="https://tangled.org">tangled.org</a> on every push. 34 + install the github app, connect your tangled identity, and your branches 35 + and tags will stay in sync. 36 + </p> 37 + 38 + <pre class="session" aria-label="How synchub works"><span class="c"># 1 · install the github app on the repos to mirror</span> 39 + <span class="p">$</span> gh app install <span class="a">synchub-to</span> 40 + 41 + <span class="c"># 2 · connect your tangled identity</span> 42 + <span class="p">$</span> synchub-to <span class="a">@alice.bsky.social</span> 43 + 44 + <span class="c"># 3 · push as usual</span> 45 + <span class="p">$</span> git push origin main 46 + <span class="ok">✓</span> mirrored alice/widget → tangled.org <span class="dim">(2 refs)</span><span class="caret" aria-hidden="true">█</span></pre> 47 + </section> 48 + 49 + <section id="start" class="panel reveal" style="--i: 1"> 50 + <h2 class="panel__title">new here?</h2> 51 + <p class="panel__body"> 52 + install the github app on the repositories you'd like to mirror, 53 + then come back and sign in with your tangled handle. (only public, 54 + non-fork repos are synced.) 55 + </p> 56 + <a class="btn btn--primary" :href="installUrl">install the github app</a> 57 + </section> 58 + 59 + <section id="signin" class="panel reveal" style="--i: 2"> 60 + <h2 class="panel__title">returning?</h2> 61 + <p class="panel__body"> 62 + already connected on another device? just enter your handle. 63 + </p> 64 + <form class="signin" action="/api/atproto/login" method="get"> 65 + <div class="signin__group"> 66 + <label class="signin__label" for="handle">handle</label> 67 + <span class="signin__field"> 68 + <span class="signin__prompt" aria-hidden="true">@</span> 69 + <input 70 + id="handle" 71 + type="text" 72 + name="handle" 73 + required 74 + placeholder="alice.bsky.social" 75 + autocomplete="username" 76 + autocapitalize="none" 77 + autocorrect="off" 78 + spellcheck="false" 79 + > 80 + </span> 81 + </div> 82 + <button class="btn" type="submit">sign in</button> 83 + </form> 84 + </section> 85 + </main> 43 86 44 - <section class="install"> 45 - <h2>New here?</h2> 46 - <p class="muted"> 47 - First install the GitHub App on the repos you'd like to mirror, 48 - then come back and sign in with your tangled handle. 49 - </p> 50 - <a class="button" :href="installUrl"> 51 - Install the GitHub App 52 - </a> 53 - </section> 54 - </main> 87 + <footer id="how" class="foot-stmt"> 88 + <p class="foot-stmt__line">code in sync.</p> 89 + <div class="foot-stmt__meta"> 90 + <SynchubMark :wordmark="true" :size="18" /> 91 + <span class="muted">© 2026 · MIT · <a href="https://github.com/danielroe/synchub.to">source</a></span> 92 + </div> 93 + </footer> 94 + </div> 55 95 </template> 56 96 57 97 <style scoped> 58 - main { 59 - max-width: 36rem; 60 - margin: 4rem auto; 61 - padding: 0 1.5rem; 62 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; 63 - line-height: 1.5; 98 + .shell { 99 + max-width: 52rem; 100 + margin: 0 auto; 101 + padding-inline: var(--page-gutter); 102 + } 103 + 104 + .nav-term { 105 + padding: var(--space-md) 0; 106 + border-bottom: var(--rule-hair) solid var(--color-rule); 64 107 } 65 - h1 { 66 - font-size: 2rem; 67 - margin-bottom: 0.25rem; 108 + 109 + .nav-term__line { 110 + display: flex; 111 + align-items: center; 112 + flex-wrap: wrap; 113 + gap: 0.6ch; 114 + font-family: var(--font-mono); 115 + font-size: var(--text-sm); 116 + margin: 0; 68 117 } 69 - h2 { 70 - font-size: 1.1rem; 71 - margin-bottom: 0.5rem; 118 + 119 + .nav-term__line .prompt { color: var(--color-accent); } 120 + 121 + .nav-term__mark { 122 + display: inline-flex; 123 + text-decoration: none; 124 + margin-right: 1.2ch; 72 125 } 73 - .muted { 74 - color: #555; 75 - margin-bottom: 0.75rem; 126 + 127 + .nav-term__mark:hover { color: inherit; } 128 + 129 + .nav-term__args { 130 + display: inline-flex; 131 + align-items: center; 132 + gap: 0.9ch; 133 + list-style: none; 134 + margin: 0; 135 + padding: 0; 76 136 } 77 - section { 78 - margin-top: 2.5rem; 79 - padding-top: 1.5rem; 80 - border-top: 1px solid #eee; 137 + 138 + .nav-term__args a { 139 + display: inline-flex; 140 + align-items: center; 141 + min-height: 44px; 142 + color: var(--color-muted); 143 + text-decoration: underline; 144 + text-underline-offset: 2px; 145 + white-space: nowrap; 81 146 } 82 - form { 147 + 148 + .nav-term__args a:hover { color: var(--color-ink); } 149 + 150 + .caret { 151 + display: inline-block; 152 + width: 1ch; 153 + color: var(--color-accent); 154 + animation: blink 1.05s steps(2) infinite; 155 + } 156 + 157 + @keyframes blink { 50% { opacity: 0; } } 158 + @media (prefers-reduced-motion: reduce) { .caret { animation: none; } } 159 + 160 + .hero { 161 + padding-block: var(--space-2xl) var(--space-xl); 162 + } 163 + 164 + .heading-target:focus { outline: none; } 165 + 166 + .hero__title { 167 + font-size: var(--text-display); 168 + line-height: 1.04; 169 + margin: 0 0 var(--space-lg); 170 + overflow-wrap: anywhere; 171 + min-width: 0; 172 + } 173 + 174 + .hero__lede { 175 + max-width: var(--measure); 176 + color: var(--color-muted); 177 + font-size: var(--text-md); 178 + margin: 0 0 var(--space-xl); 179 + } 180 + 181 + .session { 182 + font-family: var(--font-mono); 183 + font-size: var(--text-sm); 184 + line-height: 1.7; 185 + background: var(--color-paper-2); 186 + border: var(--rule-hair) solid var(--color-rule); 187 + border-radius: var(--radius-md); 188 + padding: var(--space-lg); 189 + margin: 0; 190 + overflow-x: auto; 191 + color: var(--color-ink); 192 + } 193 + 194 + .session .c { color: var(--color-neutral); } 195 + .session .p { color: var(--color-accent); } 196 + .session .a { color: var(--color-info); } 197 + .session .ok { color: var(--color-ok); } 198 + .session .dim { color: var(--color-neutral); } 199 + 200 + .panel { 201 + padding-block: var(--space-xl); 202 + border-top: var(--rule-hair) solid var(--color-rule); 203 + } 204 + 205 + .panel__title { 206 + font-size: var(--text-lg); 207 + margin: 0 0 var(--space-sm); 208 + } 209 + 210 + .panel__body { 211 + max-width: var(--measure); 212 + color: var(--color-muted); 213 + margin: 0 0 var(--space-lg); 214 + } 215 + 216 + .signin { 217 + display: flex; 218 + flex-wrap: wrap; 219 + align-items: end; 220 + gap: var(--space-sm); 221 + } 222 + 223 + .signin__group { 83 224 display: flex; 84 225 flex-direction: column; 85 - gap: 0.5rem; 226 + gap: var(--space-2xs); 227 + } 228 + 229 + .signin__label { 230 + font-family: var(--font-mono); 231 + font-size: var(--text-xs); 232 + letter-spacing: 0.06em; 233 + text-transform: uppercase; 234 + color: var(--color-muted); 86 235 } 87 - label { 236 + 237 + .signin__field { 88 238 display: flex; 89 - flex-direction: column; 90 - gap: 0.25rem; 91 - font-size: 0.875rem; 239 + align-items: center; 240 + background: var(--color-paper-2); 241 + border: var(--rule-hair) solid var(--color-rule-interactive); 242 + border-radius: var(--radius-sm); 243 + } 244 + 245 + .signin__field:focus-within { 246 + outline: 2px solid var(--color-focus); 247 + outline-offset: 2px; 248 + border-color: var(--color-accent); 92 249 } 93 - input { 94 - padding: 0.5rem 0.75rem; 95 - border: 1px solid #ccc; 96 - border-radius: 4px; 97 - font-size: 1rem; 98 - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; 250 + 251 + .signin__prompt { 252 + padding-inline: 0.75ch 0; 253 + color: var(--color-accent); 254 + font-family: var(--font-mono); 99 255 } 100 - button, .button { 101 - display: inline-block; 102 - padding: 0.5rem 1rem; 103 - background: #111; 104 - color: #fff; 256 + 257 + .signin input { 258 + flex: 1; 259 + min-width: 14rem; 260 + padding: 0.55rem 0.75rem 0.55rem 0.4ch; 105 261 border: 0; 106 - border-radius: 4px; 107 - font-size: 1rem; 262 + background: transparent; 263 + color: var(--color-ink); 264 + font-family: var(--font-mono); 265 + font-size: var(--text-base); 266 + } 267 + 268 + .signin input::placeholder { color: var(--color-neutral); } 269 + 270 + .btn { 271 + display: inline-block; 272 + padding: 0.55rem 1.1rem; 273 + border: var(--rule-hair) solid var(--color-rule-interactive); 274 + border-radius: var(--radius-sm); 275 + background: var(--color-paper-2); 276 + color: var(--color-ink); 277 + font-family: var(--font-mono); 278 + font-size: var(--text-sm); 279 + text-decoration: none; 280 + white-space: nowrap; 108 281 cursor: pointer; 109 - text-decoration: none; 110 - text-align: center; 111 - width: fit-content; 282 + transition: transform var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); 283 + } 284 + 285 + .btn:hover { border-color: var(--color-accent); transform: translateY(-1px); color: var(--color-ink); } 286 + .btn:active { transform: translateY(0); } 287 + 288 + .btn--primary { 289 + border-color: var(--color-accent); 290 + color: var(--color-accent); 291 + } 292 + 293 + .foot-stmt { 294 + display: grid; 295 + gap: var(--space-lg); 296 + padding-block: var(--space-2xl) var(--space-xl); 297 + border-top: var(--rule-hair) solid var(--color-rule); 298 + } 299 + 300 + .foot-stmt__line { 301 + font-family: var(--font-display); 302 + font-size: clamp(1.5rem, 4vw, 2.4rem); 303 + line-height: 1.05; 304 + letter-spacing: -0.02em; 305 + max-width: 22ch; 306 + margin: 0; 112 307 } 113 - button:hover, .button:hover { 114 - background: #333; 308 + 309 + .foot-stmt__meta { 310 + display: flex; 311 + justify-content: space-between; 312 + align-items: center; 313 + flex-wrap: wrap; 314 + gap: var(--space-sm); 315 + padding-top: var(--space-sm); 316 + border-top: var(--rule-hair) solid var(--color-rule); 317 + font-size: var(--text-sm); 115 318 } 319 + 320 + .muted { color: var(--color-neutral); } 321 + .muted a { color: var(--color-muted); } 116 322 </style>
+15
nuxt.config.ts
··· 12 12 '@nuxt/test-utils', 13 13 ], 14 14 devtools: { enabled: true }, 15 + css: ['~/assets/css/tokens.css', '~/assets/css/base.css'], 16 + fonts: { 17 + families: [ 18 + { name: 'JetBrains Mono', provider: 'google', weights: [400, 500, 700] }, 19 + { name: 'IBM Plex Sans', provider: 'google', weights: [300, 400, 600] }, 20 + ], 21 + }, 15 22 runtimeConfig: { 16 23 databaseUrl: '', 17 24 githubAppId: '', ··· 35 42 app: { 36 43 head: { 37 44 htmlAttrs: { lang: 'en' }, 45 + link: [ 46 + { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, 47 + { rel: 'alternate icon', href: '/favicon.ico' }, 48 + ], 38 49 }, 39 50 }, 40 51 future: { ··· 42 53 }, 43 54 experimental: { 44 55 typedPages: true, 56 + }, 57 + routeRules: { 58 + '/': { noScripts: true, prerender: true }, 59 + '/dashboard': { ssr: false, prerender: true }, 45 60 }, 46 61 nitro: { 47 62 vercel: {
+9
public/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> 2 + <rect width="32" height="32" rx="6" fill="oklch(15% 0.012 250)"/> 3 + <g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.4"> 4 + <circle cx="9" cy="9" r="3.4" stroke="oklch(94% 0.006 145)"/> 5 + <circle cx="23" cy="23" r="3.4" stroke="oklch(94% 0.006 145)"/> 6 + <path d="M9 13 V19 a4 4 0 0 0 4 4 H18.5" stroke="oklch(94% 0.006 145)"/> 7 + <path d="M16 19.5 L20 23 L16 26.5" stroke="oklch(82% 0.19 145)"/> 8 + </g> 9 + </svg>