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

6.2 KB · 203 lines · JavaScript Raw History
  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};