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