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="/" 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>
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" 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: 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 {
224 display: flex;
225 flex-direction: column;
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);
235}
236
237.signin__field {
238 display: flex;
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);
249}
250
251.signin__prompt {
252 padding-inline: 0.75ch 0;
253 color: var(--color-accent);
254 font-family: var(--font-mono);
255}
256
257.signin input {
258 flex: 1;
259 min-width: 14rem;
260 padding: 0.55rem 0.75rem 0.55rem 0.4ch;
261 border: 0;
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;
281 cursor: pointer;
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;
307}
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);
318}
319
320.muted { color: var(--color-neutral); }
321.muted a { color: var(--color-muted); }
322</style>