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 contact page chat, a scripted branching conversation with typing delays.
2// The tree stays in JS rather than in Go with the rest of the copy, since none
3// of it is ever rendered server-side.
4
5const CHAT_TREE = {
6 start: {
7 messages: ["Hey there!", "What brings you here?"],
8 options: [
9 { label: "I want to work together", next: "collab" },
10 { label: "Just checking out your work", next: "browsing" },
11 { label: "Job opportunity", next: "job" },
12 ],
13 },
14 collab: {
15 messages: [
16 "Nice! I'm always open to side projects and open source collabs.",
17 "Best way to reach me is email. Drop me a line and tell me what you're thinking.",
18 ],
19 options: [
20 { label: "What's your email?", next: "email" },
21 { label: "What kind of projects?", next: "projects" },
22 ],
23 },
24 browsing: {
25 messages: [
26 "Welcome! Feel free to look around.",
27 "If anything catches your eye or you want to chat, I'm easy to reach.",
28 ],
29 options: [
30 { label: "How do I reach you?", next: "email" },
31 { label: "What are you working on?", next: "projects" },
32 ],
33 },
34 job: {
35 messages: [
36 "I appreciate the interest.",
37 "I'm currently at Craftmaster Furniture as a Senior Solutions Architect and not actively looking.",
38 "That said, feel free to reach out if it's compelling.",
39 ],
40 options: [
41 { label: "What's the best way to reach you?", next: "email" },
42 { label: "What do you do there?", next: "work" },
43 ],
44 },
45 email: {
46 messages: [
47 "Email is best: [email protected]",
48 "You can also find me on GitHub as /overshard or on Discord as Overshard#4907.",
49 ],
50 options: [{ label: "Thanks!", next: "end" }],
51 },
52 projects: {
53 messages: [
54 "Lately I've been deep into AI agent workflows, automated testing infrastructure, and tooling for fast release cycles.",
55 "On the side I build self-hosted tools and experiment with whatever is new. Check out the Code page for more.",
56 ],
57 options: [
58 { label: "How do I reach you?", next: "email" },
59 { label: "Cool, thanks!", next: "end" },
60 ],
61 },
62 work: {
63 messages: [
64 "I focus on AI agent workflows, automated integration testing, and building systems for rapid releases without sacrificing stability or security.",
65 "Two decades of experience across the full stack, from kernel modules to regulated healthcare environments.",
66 ],
67 options: [
68 { label: "How do I reach you?", next: "email" },
69 { label: "Interesting, thanks!", next: "end" },
70 ],
71 },
72 end: {
73 messages: ["Anytime! Take care."],
74 options: [],
75 },
76};
77
78export const initContact = () => {
79 const chat = document.querySelector(".contact-chat");
80 // Handed over by the server, so the image format lives only in images.json.
81 const AVATAR = chat?.dataset.avatar || "";
82 const scroller = document.querySelector(".contact-gridRight");
83 if (!chat) return;
84
85 const scrollToEnd = () => {
86 if (scroller) scroller.scrollTop = scroller.scrollHeight;
87 };
88
89 const avatar = () => {
90 const span = document.createElement("span");
91 span.className = "contact-chatAvatar";
92 const img = document.createElement("img");
93 img.src = AVATAR;
94 img.alt = "Isaac";
95 img.width = 40;
96 img.height = 40;
97 span.appendChild(img);
98 return span;
99 };
100
101 const addMessage = (text, from) => {
102 const line = document.createElement("div");
103 line.className = "contact-chatLine";
104 if (from === "user") line.classList.add("contact-chatLineUser");
105 else line.appendChild(avatar());
106
107 const bubble = document.createElement("div");
108 bubble.className = "contact-chatBubble";
109 if (from === "user") bubble.classList.add("contact-chatBubbleUser");
110 bubble.append(document.createTextNode(text));
111
112 if (from !== "user") {
113 const name = document.createElement("span");
114 name.textContent = "Isaac";
115 bubble.appendChild(name);
116 }
117
118 line.appendChild(bubble);
119 chat.appendChild(line);
120 scrollToEnd();
121 };
122
123 const typingIndicator = () => {
124 const line = document.createElement("div");
125 line.className = "contact-chatLine";
126 line.dataset.typing = "true";
127 line.appendChild(avatar());
128
129 const bubble = document.createElement("div");
130 bubble.className = "contact-chatBubble";
131 const dots = document.createElement("span");
132 dots.className = "contact-typingDots";
133 dots.append(
134 document.createElement("span"),
135 document.createElement("span"),
136 document.createElement("span")
137 );
138 bubble.appendChild(dots);
139 line.appendChild(bubble);
140 return line;
141 };
142
143 const clearOptions = () => {
144 const existing = chat.querySelector(".contact-chatOptions");
145 if (existing) existing.remove();
146 };
147
148 const showOptions = (options) => {
149 clearOptions();
150 if (!options.length) return;
151
152 const wrapper = document.createElement("div");
153 wrapper.className = "contact-chatOptions";
154 options.forEach((option) => {
155 const button = document.createElement("button");
156 button.className = "contact-chatOption";
157 button.type = "button";
158 button.textContent = option.label;
159 button.addEventListener("click", () => {
160 clearOptions();
161 addMessage(option.label, "user");
162 if (option.next) window.setTimeout(() => play(option.next), 400);
163 });
164 wrapper.appendChild(button);
165 });
166 chat.appendChild(wrapper);
167 scrollToEnd();
168 };
169
170 // The typing indicator's dwell scales with message length, so a long reply
171 // visibly takes longer to write than a short one.
172 const play = (key) => {
173 const node = CHAT_TREE[key];
174 if (!node) return;
175
176 const queue = [...node.messages];
177
178 const next = () => {
179 if (!queue.length) {
180 showOptions(node.options);
181 return;
182 }
183
184 const text = queue.shift();
185 const indicator = typingIndicator();
186 chat.appendChild(indicator);
187 scrollToEnd();
188
189 window.setTimeout(() => {
190 indicator.remove();
191 addMessage(text, "isaac");
192 next();
193 }, 600 + text.length * 15);
194 };
195
196 next();
197 };
198
199 // Waits out the panel entrance and content stagger in contact.css, so the
200 // first typing indicator does not land on top of the left column arriving.
201 window.setTimeout(() => play("start"), 1150);
202};