repos
/ orchard main

orchard

mirror

Every 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

11.8 KB · 604 lines · SCSS Raw History
  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}