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

4.4 KB · 163 lines · CSS Raw History
  1/* The fold is a transform, not a height.
  2   
  3   Animating height relaid out the column on every frame, and because the two
  4   sheets are stacked in flow the column's content box is 200vh tall, so
  5   shrinking the front sheet reflowed the back one up through the viewport.
  6   Chrome scores every one of those frames as a layout shift: 73 of them on the
  7   home page, 0.36 CLS, three and a half times Google's 0.1 threshold and the
  8   only reason this site scored 80 on performance. None of it was an image,
  9   whatever Lighthouse's width/height audit implies. Transforms are composited
 10   and never generate a shift. */
 11@keyframes loader-foldUpFront {
 12  0% {
 13    transform: scaleY(1);
 14  }
 15  100% {
 16    transform: scaleY(0);
 17  }
 18}
 19
 20/* The back sheet used to sit one viewport below the front and get pushed up as
 21   the front shrank. translateY puts it exactly where that reflow did: at
 22   progress p the front occupies 0..100vh(1-p) and the back 100vh(1-p) upward,
 23   which is what translateY(100vh(1-p)) scaleY(1-p) resolves to. Both sheets
 24   run the same duration, delay and easing, so they interpolate to the same
 25   rectangle at every point and the curtain looks identical. */
 26@keyframes loader-foldUpBack {
 27  0% {
 28    transform: translateY(100vh) scaleY(1);
 29  }
 30  100% {
 31    transform: translateY(0) scaleY(0);
 32  }
 33}
 34
 35@keyframes loader-dotPulse {
 36  0%,
 37  80%,
 38  100% {
 39    opacity: 0.2;
 40    transform: scale(0.8);
 41  }
 42  40% {
 43    opacity: 1;
 44    transform: scale(1);
 45  }
 46}
 47
 48/* The curtain exists to cover a cold first paint that has to fetch the CSS,
 49   the bundle, the fonts and a hero image. Once a browser holds those bytes,
 50   replaying it is theatre over nothing and, worse, a full second of delay
 51   charged to a navigation the server answers in under a millisecond. The head
 52   script decides, keyed on the bundle's content hash so a deploy earns the
 53   opening back; this is display: none rather than a skipped animation so the
 54   columns never paint at all. */
 55html[data-returning] .loader {
 56  display: none;
 57}
 58
 59.loader {
 60  display: grid;
 61  grid-template-columns: 60px 15% 1fr 1fr 15% 60px;
 62  grid-template-rows: 1fr;
 63  min-height: 100vh;
 64  width: 100%;
 65  position: fixed;
 66  z-index: 9999;
 67  pointer-events: none;
 68}
 69
 70.loader-indicator {
 71  position: fixed;
 72  top: 50%;
 73  left: 50%;
 74  transform: translate(-50%, -50%);
 75  display: flex;
 76  gap: 8px;
 77  opacity: 0;
 78  transition: opacity 400ms ease-in-out;
 79  z-index: 10000;
 80  pointer-events: none;
 81}
 82
 83.loader[data-wait="true"] .loader-indicator {
 84  opacity: 1;
 85  transition-delay: 250ms;
 86}
 87
 88.loader-dot {
 89  width: 8px;
 90  height: 8px;
 91  border-radius: 50%;
 92  background-color: white;
 93  animation: loader-dotPulse 1400ms ease-in-out infinite both;
 94}
 95
 96.loader-dot:nth-child(2) {
 97  animation-delay: 160ms;
 98}
 99
100.loader-dot:nth-child(3) {
101  animation-delay: 320ms;
102}
103
104/* Out of flow, so folding one sheet cannot move the other. */
105.loader-column {
106  position: relative;
107}
108
109.loader-column::before,
110.loader-column::after {
111  content: "";
112  position: absolute;
113  top: 0;
114  left: 0;
115  width: 100%;
116  height: 100vh;
117  transform-origin: top;
118  animation-duration: 1000ms;
119  animation-fill-mode: forwards;
120  animation-play-state: running;
121}
122
123.loader-column::before {
124  animation-name: loader-foldUpFront;
125  z-index: 1;
126}
127
128.loader-column::after {
129  animation-name: loader-foldUpBack;
130}
131
132/* Staggered left to right, so the columns peel away rather than lifting as one
133   sheet. */
134.loader-column:nth-child(1)::before,
135.loader-column:nth-child(1)::after { animation-delay: 100ms; }
136.loader-column:nth-child(2)::before,
137.loader-column:nth-child(2)::after { animation-delay: 200ms; }
138.loader-column:nth-child(3)::before,
139.loader-column:nth-child(3)::after { animation-delay: 300ms; }
140.loader-column:nth-child(4)::before,
141.loader-column:nth-child(4)::after { animation-delay: 400ms; }
142.loader-column:nth-child(5)::before,
143.loader-column:nth-child(5)::after { animation-delay: 500ms; }
144.loader-column:nth-child(6)::before,
145.loader-column:nth-child(6)::after { animation-delay: 600ms; }
146
147/* Held only on the home page, until the hero image has decoded. Every other
148   page starts folding immediately, because it has nothing to wait for. */
149.loader[data-wait="true"] .loader-column::before,
150.loader[data-wait="true"] .loader-column::after {
151  animation-play-state: paused;
152}
153
154.loader-column::before {
155  background-color: var(--color-primary);
156  border-right: 1px solid var(--color-primary);
157}
158
159.loader-column::after {
160  background-color: white;
161  border-right: 1px solid white;
162}