orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1@use "../../base/styles/variables" as *;
2
3// The sign in stage. Form on the left, star field on the right, and on a narrow
4// screen the field slides behind the form rather than being dropped, because it
5// is the only thing on the page worth looking at.
6.auth-stage {
7 position: relative;
8 display: grid;
9 grid-template-columns: minmax(0, 1fr);
10 min-height: calc(100vh - 3.5rem);
11 isolation: isolate;
12 overflow: hidden;
13
14 @media (min-width: 62em) {
15 grid-template-columns: minmax(28rem, 44%) minmax(0, 1fr);
16 }
17}
18
19.auth-panel {
20 position: relative;
21 z-index: 2;
22 display: flex;
23 align-items: center;
24 padding: 3rem 1.75rem;
25 // The field runs under the form on a narrow screen rather than being dropped,
26 // so the form needs a ground of its own or the text is read against drifting
27 // stars, which falls apart under a bright light.
28 background: rgba(14, 13, 10, 0.86);
29
30 @media (min-width: 62em) {
31 padding: 4rem 4.5rem;
32 // A soft edge instead of a rule, so the field appears to bleed under the
33 // form rather than stopping at a line.
34 background: linear-gradient(
35 100deg,
36 rgba(14, 13, 10, 0.97) 0%,
37 rgba(14, 13, 10, 0.93) 52%,
38 rgba(14, 13, 10, 0.6) 78%,
39 rgba(14, 13, 10, 0) 100%
40 );
41 }
42}
43
44.auth-form {
45 width: 100%;
46 max-width: 24rem;
47}
48
49.auth-sky {
50 position: absolute;
51 inset: 0;
52 z-index: 0;
53}
54
55.auth-stars {
56 position: absolute;
57 inset: 0;
58 width: 100%;
59 height: 100%;
60 display: block;
61}
62
63// Two colour washes behind the stars. Fixed positions rather than an animation,
64// since a moving gradient this large is a full-viewport repaint every frame.
65.auth-nebula {
66 position: absolute;
67 inset: -20%;
68 background:
69 radial-gradient(40rem 32rem at 70% 26%, rgba(107, 158, 120, 0.3), transparent 66%),
70 radial-gradient(32rem 28rem at 40% 80%, rgba(126, 170, 184, 0.2), transparent 68%),
71 radial-gradient(24rem 24rem at 88% 74%, rgba(201, 168, 76, 0.1), transparent 70%);
72 pointer-events: none;
73}
74
75// A dark edge all the way round, so the field reads as depth rather than as a
76// rectangle that stops.
77.auth-vignette {
78 position: absolute;
79 inset: 0;
80 background: radial-gradient(120% 100% at 60% 45%, transparent 38%, rgba(8, 7, 5, 0.85) 100%);
81 pointer-events: none;
82}
83
84// Two dashed rings, slowly counter-rotating. This is the part that reads as
85// orbit rather than as noise.
86.auth-orbits {
87 position: absolute;
88 top: 50%;
89 left: 62%;
90 width: min(46rem, 74vw);
91 aspect-ratio: 1;
92 translate: -50% -50%;
93 pointer-events: none;
94 opacity: 1;
95
96 &::before,
97 &::after {
98 content: "";
99 position: absolute;
100 inset: 0;
101 border-radius: 50%;
102 border: 1px dashed rgba(107, 158, 120, 0.55);
103 animation: auth-spin 150s linear infinite;
104 }
105
106 &::after {
107 inset: 17%;
108 border-color: rgba(126, 170, 184, 0.45);
109 animation-duration: 96s;
110 animation-direction: reverse;
111 }
112}
113
114@keyframes auth-spin {
115 to {
116 rotate: 360deg;
117 }
118}
119
120// A field of streaking points is exactly the motion that makes some people
121// unwell, so everything that moves stops here. The canvas draws one static
122// frame of its own accord.
123@media (prefers-reduced-motion: reduce) {
124 .auth-orbits::before,
125 .auth-orbits::after {
126 animation: none;
127 }
128}
129
130.auth-mark {
131 width: 2.25rem;
132 height: 2.25rem;
133 margin-bottom: 1.5rem;
134
135 svg {
136 width: 100%;
137 height: 100%;
138 filter: drop-shadow(0 0 14px rgba(107, 158, 120, 0.4));
139 }
140}
141
142.auth-eyebrow {
143 font-size: 0.7rem;
144 letter-spacing: 0.18em;
145 text-transform: uppercase;
146 color: $green;
147 margin-bottom: 0.6rem;
148}
149
150.auth-title {
151 font-size: clamp(1.7rem, 1.2rem + 1.6vw, 2.3rem);
152 font-weight: 700;
153 letter-spacing: -0.02em;
154 color: $white;
155 margin-bottom: 1.75rem;
156}
157
158.auth-hint {
159 font-size: 0.82rem;
160 color: $gray-300;
161 margin-top: 1.5rem;
162}
163
164.auth-actions {
165 display: flex;
166 align-items: center;
167 justify-content: space-between;
168 gap: 1rem;
169 margin-top: 1.75rem;
170}
171
172// The six digit field. Wide tracking and a big face, because this is the one
173// input on the site where a transposed character is the whole failure.
174.code-input {
175 font-size: 1.65rem !important;
176 letter-spacing: 0.5em;
177 text-indent: 0.5em;
178 text-align: center;
179 padding: 1.1rem 0.75rem !important;
180 height: auto !important;
181}
182
183.auth-alert {
184 display: flex;
185 gap: 0.6rem;
186 align-items: flex-start;
187 font-size: 0.82rem;
188 padding: 0.7rem 0.85rem;
189 border-radius: $border-radius;
190 margin-bottom: 1.25rem;
191 border: 1px solid transparent;
192
193 &.is-error {
194 color: #e0a48d;
195 background: $terracotta-dim;
196 border-color: $terracotta-border;
197 }
198
199 &.is-notice {
200 color: #a8cbb3;
201 background: $green-dim;
202 border-color: $green-border;
203 }
204}
205
206// ---------------------------------------------------------------- signed in
207
208.page-head {
209 display: flex;
210 align-items: flex-end;
211 justify-content: space-between;
212 flex-wrap: wrap;
213 gap: 1rem;
214 margin-bottom: 2rem;
215}
216
217.page-title {
218 font-size: clamp(1.5rem, 1.2rem + 1vw, 2rem);
219 font-weight: 700;
220 letter-spacing: -0.02em;
221 color: $white;
222 margin: 0;
223}
224
225.panel {
226 position: relative;
227 background: linear-gradient(180deg, #262319, #1a1813);
228 border: 1px solid $card-border-color;
229 border-radius: 0.6rem;
230 padding: 1.5rem;
231 height: 100%;
232
233 // A one pixel highlight along the top edge, which is what stops a flat card
234 // from reading as a grey rectangle on a dark ground.
235 &::before {
236 content: "";
237 position: absolute;
238 inset: 0 0 auto 0;
239 height: 1px;
240 background: linear-gradient(
241 90deg,
242 transparent,
243 rgba(125, 184, 140, 0.6),
244 transparent
245 );
246 }
247}
248
249.panel-title {
250 font-size: 0.7rem;
251 letter-spacing: 0.16em;
252 text-transform: uppercase;
253 color: $gray-400;
254 margin-bottom: 1.1rem;
255}
256
257.field-list {
258 display: grid;
259 gap: 0.7rem;
260 font-size: 0.85rem;
261
262 > div {
263 display: flex;
264 justify-content: space-between;
265 gap: 1rem;
266 padding-bottom: 0.7rem;
267 border-bottom: 1px solid rgba(221, 215, 205, 0.16);
268 }
269
270 > div:last-child {
271 border-bottom: 0;
272 padding-bottom: 0;
273 }
274
275 dt,
276 .k {
277 color: $gray-400;
278 }
279
280 dd,
281 .v {
282 color: $white;
283 margin: 0;
284 text-align: right;
285 word-break: break-word;
286 }
287}
288
289// The recovery code meter. Ten pips beat a number, because the number needs
290// reading and eight lit pips out of ten does not.
291.pips {
292 display: flex;
293 gap: 0.3rem;
294 margin: 0.85rem 0 1.1rem;
295}
296
297.pip {
298 height: 0.42rem;
299 flex: 1;
300 border-radius: 999px;
301 background: rgba(221, 215, 205, 0.18);
302
303 &.is-lit {
304 background: $green;
305 box-shadow: 0 0 10px rgba(107, 158, 120, 0.45);
306 }
307
308 &.is-low {
309 background: $terracotta;
310 box-shadow: 0 0 10px rgba(196, 112, 85, 0.45);
311 }
312}
313
314.stat {
315 font-size: 2.6rem;
316 font-weight: 700;
317 line-height: 1;
318 color: $white;
319 letter-spacing: -0.03em;
320
321 &.is-low {
322 color: $terracotta;
323 }
324}
325
326// One session, as a row. A list rather than a table, so the phone gets a
327// readable stack instead of a sideways scroll.
328.session {
329 display: flex;
330 align-items: center;
331 gap: 1rem;
332 padding: 1rem 0;
333 border-bottom: 1px solid rgba(221, 215, 205, 0.16);
334 flex-wrap: wrap;
335
336 &:last-child {
337 border-bottom: 0;
338 }
339}
340
341.session-dot {
342 width: 0.5rem;
343 height: 0.5rem;
344 border-radius: 50%;
345 background: $gray-400;
346 flex: none;
347
348 &.is-current {
349 background: $green;
350 box-shadow: 0 0 0 3px rgba(107, 158, 120, 0.3);
351 }
352}
353
354.session-main {
355 flex: 1 1 12rem;
356 min-width: 0;
357}
358
359.session-where {
360 color: $white;
361 font-size: 0.92rem;
362}
363
364.session-meta {
365 color: $gray-400;
366 font-size: 0.78rem;
367 margin-top: 0.15rem;
368}
369
370.tag {
371 display: inline-block;
372 font-size: 0.65rem;
373 letter-spacing: 0.1em;
374 text-transform: uppercase;
375 padding: 0.15rem 0.45rem;
376 border-radius: 0.25rem;
377 color: $green-bright;
378 background: $green-dim;
379 border: 1px solid $green-border;
380 vertical-align: middle;
381 margin-left: 0.4rem;
382}
383
384// The activity feed, as a timeline. The rail is what turns a list of rows into
385// something you read top down.
386.feed {
387 position: relative;
388 padding-left: 1.35rem;
389
390 &::before {
391 content: "";
392 position: absolute;
393 left: 0.28rem;
394 top: 0.4rem;
395 bottom: 0.4rem;
396 width: 1px;
397 background: rgba(221, 215, 205, 0.18);
398 }
399}
400
401.event {
402 position: relative;
403 padding: 0.6rem 0;
404 font-size: 0.85rem;
405 display: flex;
406 gap: 1rem;
407 flex-wrap: wrap;
408
409 &::before {
410 content: "";
411 position: absolute;
412 left: -1.35rem;
413 top: 1rem;
414 width: 0.42rem;
415 height: 0.42rem;
416 border-radius: 50%;
417 background: $gray-400;
418 }
419
420 &.is-note::before {
421 background: $green;
422 }
423
424 &.is-warn::before {
425 background: $amber;
426 }
427}
428
429.event-what {
430 color: $white;
431 flex: 1 1 11rem;
432}
433
434.event-when {
435 color: $gray-500;
436 font-size: 0.78rem;
437 flex: none;
438}
439
440.event-where {
441 color: $gray-400;
442 font-size: 0.78rem;
443}
444
445// The recovery codes, once. Big, spaced, and selectable in one go.
446.codes {
447 display: grid;
448 grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
449 gap: 0.55rem;
450 margin: 1.25rem 0;
451}
452
453.code-chip {
454 font-size: 1rem;
455 letter-spacing: 0.06em;
456 color: $green-bright;
457 background: rgba(107, 158, 120, 0.14);
458 border: 1px solid $green-border;
459 border-radius: 0.35rem;
460 padding: 0.7rem 0.5rem;
461 text-align: center;
462}
463
464.btn.is-done {
465 color: $green-bright;
466 border-color: $green-border;
467}
468
469.link-list {
470 display: flex;
471 flex-wrap: wrap;
472 gap: 0.5rem;
473}
474
475// The six digit code, one box per character. Wide enough to be tapped on a
476// phone without zooming, which is the whole reason the single field was hard.
477.otp {
478 display: grid;
479 grid-template-columns: repeat(6, 1fr);
480 gap: 0.5rem;
481 margin-bottom: 0.25rem;
482}
483
484.otp-box {
485 width: 100%;
486 aspect-ratio: 3 / 4;
487 min-height: 3.2rem;
488 text-align: center;
489 font-size: 1.5rem;
490 font-family: inherit;
491 color: $white;
492 background: $input-bg;
493 border: 1px solid $input-border-color;
494 border-radius: 0.4rem;
495 padding: 0;
496 caret-color: $green;
497 transition: border-color 0.12s ease, box-shadow 0.12s ease;
498
499 &:focus {
500 outline: none;
501 border-color: $green;
502 box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.3);
503 background: $input-focus-bg;
504 }
505
506 // A filled box reads as done at a glance, which is what makes six of these
507 // better than one field.
508 &:not(:placeholder-shown),
509 &:valid:not(:focus) {
510 border-color: $green-border;
511 }
512}
513
514@media (max-width: 24em) {
515 .otp {
516 gap: 0.35rem;
517 }
518
519 .otp-box {
520 min-height: 2.7rem;
521 font-size: 1.25rem;
522 }
523}
524
525// The other sites, as cards rather than a row of buttons. Each one is the whole
526// link, so the hit area is the card and not the four words inside it.
527.site-grid {
528 display: grid;
529 grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
530 gap: 0.75rem;
531}
532
533.site-card {
534 display: block;
535 padding: 1rem 1.1rem;
536 border-radius: 0.5rem;
537 border: 1px solid rgba(221, 215, 205, 0.18);
538 background: rgba(221, 215, 205, 0.05);
539 text-decoration: none !important;
540 transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
541
542 &:hover,
543 &:focus-visible {
544 border-color: $green-border;
545 background: rgba(107, 158, 120, 0.14);
546 transform: translateY(-2px);
547 }
548
549 &:hover .site-card-go,
550 &:focus-visible .site-card-go {
551 opacity: 1;
552 translate: 2px 0;
553 }
554}
555
556.site-card-name {
557 display: flex;
558 align-items: center;
559 gap: 0.5rem;
560 color: $white;
561 font-size: 0.95rem;
562}
563
564.site-card-dot {
565 width: 0.4rem;
566 height: 0.4rem;
567 border-radius: 50%;
568 background: $green;
569 flex: none;
570 box-shadow: 0 0 8px rgba(107, 158, 120, 0.6);
571}
572
573.site-card-go {
574 margin-left: auto;
575 color: $green-bright;
576 opacity: 0;
577 transition: opacity 0.15s ease, translate 0.15s ease;
578}
579
580.site-card-desc {
581 color: $gray-400;
582 font-size: 0.78rem;
583 margin-top: 0.4rem;
584}
585
586.site-card-host {
587 color: $gray-500;
588 font-size: 0.7rem;
589 margin-top: 0.55rem;
590 letter-spacing: 0.04em;
591}
592
593@media (prefers-reduced-motion: reduce) {
594 .site-card,
595 .site-card-go {
596 transition: none;
597 }
598
599 .site-card:hover,
600 .site-card:focus-visible {
601 transform: none;
602 }
603}