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/* 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}