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@keyframes contact-transformRight {
2 from {
3 transform: scaleX(0);
4 }
5 to {
6 transform: scaleX(1);
7 }
8}
9
10@keyframes contact-transformLeft {
11 from {
12 transform: scaleX(1);
13 }
14 to {
15 transform: scaleX(0);
16 }
17}
18
19/* The two halves arrive from opposite edges and meet at the seam, rather than
20 the whole grid sliding in from the left as one sheet. Off screen start is
21 -120% on the left panel and not -100%: the grid is inset by the 60px
22 sidebar, so an exact -100% would park its right edge at x=60 and leave a
23 dark sliver showing behind the sidebar for the length of the animation. */
24@keyframes contact-panelInLeft {
25 from {
26 transform: translateX(-120%);
27 }
28 to {
29 transform: translateX(0);
30 }
31}
32
33@keyframes contact-panelInRight {
34 from {
35 transform: translateX(100%);
36 }
37 to {
38 transform: translateX(0);
39 }
40}
41
42/* Everything below is the content stagger inside the dark panel, which only
43 starts once the panels have landed. The delays are absolute rather than
44 relative so the whole sequence reads as one timeline from page load. */
45@keyframes contact-ruleDraw {
46 from {
47 transform: scaleX(0);
48 }
49 to {
50 transform: scaleX(1);
51 }
52}
53
54@keyframes contact-riseIn {
55 from {
56 opacity: 0;
57 transform: translateY(12px);
58 }
59 to {
60 opacity: 1;
61 transform: translateY(0);
62 }
63}
64
65@keyframes contact-barIn {
66 from {
67 opacity: 0;
68 transform: translateX(-28px);
69 }
70 to {
71 opacity: 1;
72 transform: translateX(0);
73 }
74}
75
76.contact-background {
77 position: fixed;
78 top: 0;
79 left: 0;
80 right: 0;
81 bottom: 0;
82 background-color: white;
83 z-index: -2;
84}
85
86.contact-grid {
87 display: grid;
88 /* Lands the seam on the centre alignment line rather than 60px past it. The
89 line grid is 60px 15% 1fr 1fr 15% 60px, which is symmetric, so its middle
90 border sits at exactly 50vw at any width. This grid is inset by the 60px
91 sidebar, so a plain 50vw column ends at 60px + 50vw and overshoots by the
92 width of that inset. */
93 grid-template-columns: calc(50vw - 60px) 1fr;
94 grid-template-rows: auto;
95 grid-template-areas: "left right";
96 margin-left: 60px;
97 width: calc(100vw - 60px);
98}
99
100@media (max-width: 1023.98px) {
101 .contact-grid {
102 margin-left: 0;
103 width: 100%;
104 grid-template-columns: 1fr;
105 grid-template-rows: auto auto;
106 grid-template-areas:
107 "left"
108 "right";
109 margin-top: 60px;
110 }
111}
112
113.contact-gridLeft {
114 grid-area: left;
115 animation: contact-panelInLeft 550ms 100ms cubic-bezier(0.16, 1, 0.3, 1) both;
116 background-color: rgba(0, 0, 0, 0.9);
117 min-height: 100vh;
118 display: flex;
119 align-items: center;
120}
121
122@media (max-width: 1023.98px) {
123 .contact-gridLeft {
124 grid-column: 1;
125 grid-row: 1;
126 min-height: auto;
127 align-items: flex-start;
128 }
129}
130
131.contact-gridRight {
132 grid-area: right;
133 /* Nothing marks this panel's leading edge. A gradient seam and then a soft
134 shadow were both tried here and both rejected as looking wrong, so the
135 white simply arrives over white: the entrance that reads is the dark
136 column plus the staggered content, and the grid lines below stay put
137 through all of it. */
138 animation: contact-panelInRight 550ms 100ms cubic-bezier(0.16, 1, 0.3, 1) both;
139 height: 100vh;
140 width: 100%;
141 display: flex;
142 align-items: flex-end;
143 overflow-y: auto;
144 background-color: white;
145}
146
147@media (max-width: 1023.98px) {
148 .contact-gridRight {
149 grid-column: 1;
150 grid-row: 2;
151 height: auto;
152 min-height: 50vh;
153 overflow-y: visible;
154 align-items: flex-start;
155 padding-top: 20px;
156 padding-bottom: 30px;
157 }
158}
159
160.contact-contactWrapper {
161 padding-left: 60px;
162}
163
164@media (max-width: 1023.98px) {
165 .contact-contactWrapper {
166 min-height: auto;
167 padding: 10px 20px 30px;
168 }
169}
170
171.contact-heading {
172 font-size: 3em;
173 margin-top: 10px;
174 margin-bottom: 20px;
175 animation: contact-riseIn 400ms 650ms ease-out both;
176}
177
178.contact-heading::before {
179 content: "";
180 display: block;
181 width: 60px;
182 height: 4px;
183 margin-bottom: 10px;
184 background-image: linear-gradient(
185 to right,
186 var(--color-blue) 0,
187 var(--color-purple) 100%
188 );
189 transform-origin: left;
190 animation: contact-ruleDraw 400ms 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
191}
192
193@media (max-width: 767.98px) {
194 .contact-heading {
195 margin-top: 0;
196 margin-bottom: 10px;
197 }
198}
199
200.contact-shoutOut {
201 text-transform: uppercase;
202 animation: contact-barIn 400ms cubic-bezier(0.16, 1, 0.3, 1) both;
203 padding: 5px;
204 margin: 0 0 2px 0;
205 float: left;
206 clear: left;
207 font-size: 1.4em;
208 background-image: linear-gradient(
209 to right,
210 var(--color-blue) 0,
211 var(--color-purple) 100%
212 );
213}
214
215@media (max-width: 767.98px) {
216 .contact-shoutOut {
217 font-size: 1.1em;
218 }
219}
220
221/* The bars and the contact pairs are staggered with nth-of-type and
222 nth-child rather than a per element delay written into the template,
223 because the markup is a plain h2 pair and a dl. The dl buckets are per
224 pair (dt then dd share a delay) and the last bucket is open ended, so
225 adding a fifth contact method degrades to landing with the fourth
226 instead of not animating at all. */
227.contact-shoutOut:nth-of-type(1) {
228 animation-delay: 800ms;
229}
230
231.contact-shoutOut:nth-of-type(2) {
232 animation-delay: 880ms;
233}
234
235.contact-contactList {
236 padding-top: 30px;
237 clear: both;
238}
239
240.contact-contactList dt,
241.contact-contactList dd {
242 animation: contact-riseIn 400ms ease-out both;
243}
244
245.contact-contactList > :nth-child(-n + 2) {
246 animation-delay: 960ms;
247}
248
249.contact-contactList > :nth-child(n + 3):nth-child(-n + 4) {
250 animation-delay: 1030ms;
251}
252
253.contact-contactList > :nth-child(n + 5):nth-child(-n + 6) {
254 animation-delay: 1100ms;
255}
256
257.contact-contactList > :nth-child(n + 7) {
258 animation-delay: 1170ms;
259}
260
261.contact-contactKey {
262 text-transform: uppercase;
263 font-size: 0.75em;
264 font-weight: 700;
265 color: rgba(255, 255, 255, 0.4);
266 letter-spacing: 2px;
267 font-family: monospace;
268}
269
270.contact-contactValue {
271 font-size: 1.5em;
272 margin-left: 0;
273 margin-bottom: 30px;
274}
275
276.contact-contactLink {
277 color: white;
278 text-decoration: none;
279 position: relative;
280}
281
282.contact-contactLink::before {
283 content: "";
284 position: absolute;
285 left: 0;
286 right: 0;
287 bottom: 0;
288 height: 2px;
289 background: rgba(255, 255, 255, 1);
290 transform-origin: left;
291 animation: contact-transformLeft 300ms normal forwards;
292}
293
294.contact-contactLink::after {
295 content: "";
296 position: absolute;
297 left: 0;
298 right: 0;
299 bottom: 0;
300 height: 2px;
301 background: rgba(255, 255, 255, 0.2);
302}
303
304.contact-contactLink:hover::before {
305 animation: contact-transformRight 300ms normal forwards;
306}
307
308.contact-chat {
309 display: flex;
310 flex-direction: column;
311 justify-content: flex-end;
312 padding: 30px;
313 width: 100%;
314 min-height: 100%;
315}
316
317@media (max-width: 1023.98px) {
318 .contact-chat {
319 justify-content: flex-start;
320 }
321}
322
323.contact-chatLine {
324 display: flex;
325 margin-bottom: 12px;
326 align-items: flex-start;
327 animation: contact-riseIn 300ms ease-out both;
328}
329
330.contact-chatLineUser {
331 justify-content: flex-end;
332}
333
334.contact-chatBubble {
335 font-size: 1.1em;
336 padding: 12px 18px;
337 background: rgba(0, 0, 0, 0.06);
338 border-radius: var(--radius-2);
339 color: black;
340 max-width: 75%;
341 line-height: 1.5;
342}
343
344@media (max-width: 767.98px) {
345 .contact-chatBubble {
346 font-size: 1em;
347 }
348}
349
350.contact-chatBubble span {
351 display: block;
352 font-size: 0.65em;
353 text-transform: uppercase;
354 margin-top: 8px;
355 color: var(--color-blue);
356 letter-spacing: 2px;
357 font-weight: 700;
358 font-family: monospace;
359}
360
361.contact-chatAvatar {
362 margin-right: 16px;
363 width: 40px;
364 height: 40px;
365 display: inline-flex;
366 flex-shrink: 0;
367}
368
369@media (max-width: 767.98px) {
370 .contact-chatAvatar {
371 margin-right: 10px;
372 width: 36px;
373 height: 36px;
374 }
375}
376
377.contact-chatBubbleUser {
378 background-image: linear-gradient(
379 to right,
380 var(--color-blue) 0,
381 var(--color-purple) 100%
382 );
383 color: white;
384}
385
386.contact-chatBubbleUser span {
387 display: none;
388}
389
390.contact-chatOptions {
391 display: flex;
392 flex-wrap: wrap;
393 gap: 8px;
394 animation: contact-riseIn 300ms ease-out both;
395 margin-top: 4px;
396 margin-bottom: 12px;
397 padding-left: 56px;
398}
399
400@media (max-width: 767.98px) {
401 .contact-chatOptions {
402 padding-left: 0;
403 }
404}
405
406.contact-chatOption {
407 padding: 8px 16px;
408 font-size: 0.9em;
409 font-weight: 600;
410 background: none;
411 border: 1px solid rgba(0, 0, 0, 0.2);
412 border-radius: var(--radius-2);
413 color: black;
414 cursor: none;
415 transition: border-color 200ms, background 200ms;
416 font-family: inherit;
417}
418
419.contact-chatOption:hover {
420 border-color: var(--color-blue);
421 background: rgba(14, 63, 244, 0.05);
422}
423
424@keyframes contact-dotPulse {
425 0%, 80%, 100% {
426 opacity: 0.3;
427 }
428 40% {
429 opacity: 1;
430 }
431}
432
433.contact-typingDots {
434 display: inline-flex;
435 gap: 4px;
436 align-items: center;
437}
438
439.contact-typingDots span {
440 display: inline-block;
441 width: 6px;
442 height: 6px;
443 background: rgba(0, 0, 0, 0.4);
444 border-radius: 50%;
445 animation: contact-dotPulse 1.4s infinite;
446 margin-top: 0;
447 font-size: inherit;
448 letter-spacing: normal;
449 text-transform: none;
450 font-weight: normal;
451 font-family: inherit;
452 color: transparent;
453}
454
455.contact-typingDots span:nth-child(2) {
456 animation-delay: 0.2s;
457}
458
459.contact-typingDots span:nth-child(3) {
460 animation-delay: 0.4s;
461}
462
463/* Reduced motion keeps the page and drops the choreography: panels are simply
464 in place, content is simply visible. The hover underline and the typing dots
465 stay, since they are feedback rather than entrance decoration. */
466@media (prefers-reduced-motion: reduce) {
467 .contact-gridLeft,
468 .contact-gridRight,
469 .contact-heading,
470 .contact-heading::before,
471 .contact-shoutOut,
472 .contact-contactList dt,
473 .contact-contactList dd,
474 .contact-chatLine,
475 .contact-chatOptions {
476 animation: none;
477 }
478}