mirror your GitHub repos to tangled.org automatically
1<script setup lang="ts">
2// If already signed in, bounce to the dashboard.
3const { data: session } = await useFetch('/api/me/whoami', {
4 // Don't blow up the page on the (very common) 401.
5 ignoreResponseError: true,
6})
7
8if (session.value && typeof session.value === 'object' && 'did' in session.value) {
9 await navigateTo('/dashboard')
10}
11
12const installUrl = 'https://github.com/apps/synchub-to/installations/new'
13
14const heading = useTemplateRef('heading')
15const router = useRouter()
16router.afterEach(() => {
17 nextTick(() => heading.value?.focus({ focusVisible: false }))
18})
19</script>
20
21<template>
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">></span> <a class="nav-term__mark" href="/"><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>
27
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" role="img" 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>
86
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>
95</template>
96
97<style scoped>
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);
107}
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;
117}
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;
125}
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;
136}
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;
146}
147
148.nav-term__args a:hover { color: var(--color-ink); }
149
150.caret {
151 display: inline-block;
152 width: 0.55ch;
153 height: 1em;
154 margin-left: 0.15ch;
155 vertical-align: -0.12em;
156 background: var(--color-accent);
157 animation: blink 1.1s var(--ease-in-out) infinite;
158}
159
160@keyframes blink { 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } }
161@media (prefers-reduced-motion: reduce) { .caret { animation: none; opacity: 1; } }
162
163.hero {
164 padding-block: var(--space-2xl) var(--space-xl);
165}
166
167.heading-target:focus { outline: none; }
168
169.hero__title {
170 font-size: var(--text-display);
171 line-height: 1.04;
172 margin: 0 0 var(--space-lg);
173 overflow-wrap: anywhere;
174 min-width: 0;
175}
176
177.hero__lede {
178 max-width: var(--measure);
179 color: var(--color-muted);
180 font-size: var(--text-md);
181 margin: 0 0 var(--space-xl);
182}
183
184.session {
185 font-family: var(--font-mono);
186 font-size: var(--text-sm);
187 line-height: 1.7;
188 background: var(--color-paper-2);
189 border: var(--rule-hair) solid var(--color-rule);
190 border-radius: var(--radius-md);
191 padding: var(--space-lg);
192 margin: 0;
193 overflow-x: auto;
194 color: var(--color-ink);
195}
196
197.session .c { color: var(--color-neutral); }
198.session .p { color: var(--color-accent); }
199.session .a { color: var(--color-info); }
200.session .ok { color: var(--color-ok); }
201.session .dim { color: var(--color-neutral); }
202
203.panel {
204 padding-block: var(--space-xl);
205 border-top: var(--rule-hair) solid var(--color-rule);
206}
207
208.panel__title {
209 font-size: var(--text-lg);
210 margin: 0 0 var(--space-sm);
211}
212
213.panel__body {
214 max-width: var(--measure);
215 color: var(--color-muted);
216 margin: 0 0 var(--space-lg);
217}
218
219.signin {
220 display: flex;
221 flex-wrap: wrap;
222 align-items: end;
223 gap: var(--space-sm);
224}
225
226.signin__group {
227 display: flex;
228 flex-direction: column;
229 gap: var(--space-2xs);
230}
231
232.signin__label {
233 font-family: var(--font-mono);
234 font-size: var(--text-xs);
235 letter-spacing: 0.06em;
236 text-transform: uppercase;
237 color: var(--color-muted);
238}
239
240.signin__field {
241 display: flex;
242 align-items: center;
243 background: var(--color-paper-2);
244 border: var(--rule-hair) solid var(--color-rule-interactive);
245 border-radius: var(--radius-sm);
246}
247
248.signin__field:focus-within {
249 outline: 2px solid var(--color-focus);
250 outline-offset: 2px;
251 border-color: var(--color-accent);
252}
253
254.signin__prompt {
255 padding-inline: 0.75ch 0;
256 color: var(--color-accent);
257 font-family: var(--font-mono);
258}
259
260.signin input:focus-visible {
261 outline: none;
262}
263
264.signin input {
265 flex: 1;
266 min-width: 14rem;
267 padding: 0.55rem 0.75rem 0.55rem 0.4ch;
268 border: 0;
269 background: transparent;
270 color: var(--color-ink);
271 font-family: var(--font-mono);
272 font-size: var(--text-base);
273}
274
275.signin input::placeholder { color: var(--color-neutral); }
276
277.btn {
278 display: inline-block;
279 padding: 0.55rem 1.1rem;
280 border: var(--rule-hair) solid var(--color-rule-interactive);
281 border-radius: var(--radius-sm);
282 background: var(--color-paper-2);
283 color: var(--color-ink);
284 font-family: var(--font-mono);
285 font-size: var(--text-sm);
286 text-decoration: none;
287 white-space: nowrap;
288 cursor: pointer;
289 transition: transform var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out);
290}
291
292.btn:hover { border-color: var(--color-accent); transform: translateY(-1px); color: var(--color-ink); }
293.btn:active { transform: translateY(0); }
294
295.btn--primary {
296 border-color: var(--color-accent);
297 color: var(--color-accent);
298}
299
300.foot-stmt {
301 display: grid;
302 gap: var(--space-lg);
303 padding-block: var(--space-2xl) var(--space-xl);
304 border-top: var(--rule-hair) solid var(--color-rule);
305}
306
307.foot-stmt__line {
308 font-family: var(--font-display);
309 font-size: clamp(1.5rem, 4vw, 2.4rem);
310 line-height: 1.05;
311 letter-spacing: -0.02em;
312 max-width: 22ch;
313 margin: 0;
314}
315
316.foot-stmt__meta {
317 display: flex;
318 justify-content: space-between;
319 align-items: center;
320 flex-wrap: wrap;
321 gap: var(--space-sm);
322 padding-top: var(--space-sm);
323 border-top: var(--rule-hair) solid var(--color-rule);
324 font-size: var(--text-sm);
325}
326
327.muted { color: var(--color-neutral); }
328.muted a { color: var(--color-muted); }
329</style>