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 home page: one huge washed-out word behind the hero, cycling every two
2// seconds. Each change is an old word falling out downward while the new one
3// drops in from above.
4
5import { enter, exit } from "../transition.js";
6
7const WORDS = ["AI Agents", "Automation", "DevOps", "Architecture"];
8const CYCLE_MS = 2000;
9const TRANSITION_MS = 1000;
10
11const CLASSES = {
12 appear: {
13 from: "index-wordAppear",
14 active: "index-wordAppearActive",
15 done: "index-wordAppearDone",
16 },
17 enter: {
18 from: "index-wordEnter",
19 active: "index-wordEnterActive",
20 done: "index-wordEnterDone",
21 },
22 exit: {
23 from: "index-wordExit",
24 active: "index-wordExitActive",
25 clear: ["index-wordEnterDone", "index-wordAppearDone"],
26 },
27};
28
29export const initHome = () => {
30 const container = document.querySelector(".index-words");
31 const hero = document.querySelector(".index-hero");
32 const image = document.querySelector(".index-imageWrapper img");
33
34 // The hero animation is held until the image is there, so the text does not
35 // fade up over an empty rectangle. Both paths release the loader, including
36 // the failure path: a broken image must not strand the page behind it.
37 if (image) {
38 const ready = () => {
39 if (hero) hero.style.animationPlayState = "running";
40 window.dispatchEvent(new Event("loaderReady"));
41 };
42 if (image.complete) ready();
43 else {
44 image.addEventListener("load", ready, { once: true });
45 image.addEventListener("error", ready, { once: true });
46 }
47 }
48
49 if (!container) return;
50
51 const makeWord = (text) => {
52 const el = document.createElement("h3");
53 el.className = "index-word";
54 el.textContent = text;
55 return el;
56 };
57
58 let index = 0;
59 let current = makeWord(WORDS[index]);
60 container.appendChild(current);
61 enter(current, CLASSES.appear, TRANSITION_MS);
62
63 window.setInterval(() => {
64 // setInterval keeps firing in a hidden tab and requestAnimationFrame does
65 // not, and enter() and exit() both defer their work behind it, so without
66 // this a backgrounded tab piles up words and bursts them all on return.
67 if (document.hidden) return;
68
69 const outgoing = current;
70 index = (index + 1) % WORDS.length;
71
72 const incoming = makeWord(WORDS[index]);
73 container.appendChild(incoming);
74 current = incoming;
75 enter(incoming, CLASSES.enter, TRANSITION_MS);
76
77 exit(outgoing, CLASSES.exit, TRANSITION_MS, (el) => el.remove());
78 }, CYCLE_MS);
79};