This is the whole build of the FAQ chat, in the order it was written: the questions page of a made-up electric-bike brand, VOLTA. Instead of a wall of questions, you ask: tap a suggestion or type your own words, three dots show that VOLTA is typing, and the answer arrives as a chat message. Some answers are rich: the range answer has a picture of the bike and a battery slider that changes the numbers. Thumbs say whether it helped, and a person is one tap away. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework and no build step in the browser: ask.js is the chat, match.js
understands free text, icons.js holds the icons, and they are ES modules loaded by one
<script type="module">. One small Python script (scripts/build.py in the download)
writes the two pages from one dataset of 24 questions: it renders every answer once, as static HTML. Stages 1 to 8 build
the Lifestyle page; stage 9 is the download exactly, with the Bold page on the same engine.
1 · The FAQ as a page, with no JavaScript
Start with what works everywhere: a page that holds every answer. The intro sits over a photo of a rider on a coastal
road, and the 24 questions sit in an accordion of native <details>, grouped in six topics. Without
JavaScript this is the whole page; with it, the same accordion becomes the list view (stage 7), and the chat copies its
answers (stage 2).
<section class="intro" aria-labelledby="faq-title">
<h1 id="faq-title" data-fit>FAQ</h1>
<p class="lede">Ask anything about your VOLTA One. Answers come back like a chat.</p>
</section>
<details class="faq-item" id="faq-delivery-time" data-q="delivery-time"><summary><span class="faq-q">How long does delivery take?</span><svg class="i faq-ico" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></summary><div class="faq-a"><div class="ans" data-q="delivery-time"><p>Bikes in stock leave our warehouse in Utrecht within one working day. Delivery to the door is free everywhere except the UK and Switzerland.</p><div class="ans-table-wrap"><table class="ans-table"><thead><tr><th scope="col">Where</th><th scope="col">Time</th><th scope="col">Cost</th></tr></thead><tbody><tr><th scope="row">Netherlands, Belgium, Luxembourg, Germany</th><td>3–5 working days</td><td>Free</td></tr><tr><th scope="row">France, Austria, Denmark, Spain, Portugal, Italy, Ireland, Sweden</th><td>5–8 working days</td><td>Free</td></tr><tr><th scope="row">UK, Switzerland</th><td>7–10 working days</td><td>€89, duties included</td></tr></tbody></table></div><p>The day before, you pick a 2-hour delivery window by text or email.</p></div></div></details>
Two rules decide what a visitor without JavaScript sees: everything marked data-js-only goes, and so does
the slider of the range answer, which shows its standard numbers instead.
/* ---------- no-JS: the accordion is the page; with JS the chat is, and the list is a view */
.nojs [data-js-only], .nojs .js-only { display: none !important; }
.nojs .range-ctl { display: none; }
The photo is fixed behind everything and right-aligned, so the rider sits as far left as cover allows. A
veil of two gradients keeps the top bar readable.
.scene { position: fixed; inset: 0; z-index: -1; }
.scene img { width: 100%; height: 100%; object-fit: cover; object-position: 100% 50%; }
@media (max-aspect-ratio: 1/1) { .scene img { object-position: 15% 50%; } }
@media (max-aspect-ratio: 3/5) { .scene img { object-position: 50% 50%; } }
.scene::after { content: ""; position: absolute; inset: 0; background: var(--scene-veil); }
The intro column starts just right of the rider. build.py writes the rider's measured box into CSS
variables (here his right edge, --subj-r, is 0.4 of the photo), and the column's left edge is worked out from
how wide the photo is drawn: the screen's width, or 1.79 × its height, whichever is bigger.
/* the intro column starts just right of the rider in the photo (the photo is right-aligned, so the rider sits as far left as it can) */
--photo-w: max(100vw, calc(var(--photo-ar, 1.79) * 100svh));
--intro-x: max(clamp(20px, 7.4vw, 112px), calc(100vw - (1 - var(--subj-r, .4)) * var(--photo-w) + 36px));
The stage is a two-column grid: the intro, and a panel clamp(370px, 34vw, 520px) wide. The left padding
never lets the intro get narrower than 290 px.
.stage { --panel-w: clamp(370px, 34vw, 520px); --pad-r: clamp(20px, 5.5vw, 96px); --gap: clamp(24px, 3vw, 48px);
position: relative; min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr) var(--panel-w); column-gap: var(--gap);
/* right of the rider, but never narrower than 290px: on short laptop screens it may reach the handlebar */
padding: 96px var(--pad-r) 28px max(clamp(20px, 5vw, 112px), min(var(--intro-x), calc(100vw - var(--pad-r) - var(--panel-w) - var(--gap) - 290px)));
align-items: start; }
The panel is frosted glass: white at 64 %, a 26 px blur with 1.5 × saturation, a thin bright edge and a long soft shadow.
.faq-list { grid-column: 2; grid-row: 1 / span 2; max-height: calc(100svh - 96px - 28px); overflow-y: auto; overscroll-behavior: contain; padding: 22px 22px 26px;
background: var(--glass); border: 1px solid var(--glass-line); border-radius: var(--radius); -webkit-backdrop-filter: blur(26px) saturate(1.5); backdrop-filter: blur(26px) saturate(1.5);
box-shadow: 0 40px 90px -30px rgba(15, 30, 50, .45); }

2 · The chat: answers as bubbles
A one-line script in the head swaps the nojs class for js before the first paint, so the
no-JS rules never flash.
(function(){var d=document.documentElement;d.classList.remove('nojs');d.classList.add('js');})();
The chat is a glass panel of its own: a header, the log (role="log", read out politely), and a dock of
suggestion chips.
<section class="chat" aria-label="Ask VOLTA" data-ask="panel" data-js-only data-fit>
<header class="chat-head">
<span class="avatar" aria-hidden="true">V</span>
<div class="chat-id"><h2 class="chat-name">VOLTA</h2><p class="chat-status"><i aria-hidden="true"></i>Answers instantly<span class="status-more"> · team online</span></p></div>
</header>
<div class="chat-log" data-ask="log" role="log" aria-live="polite" aria-relevant="additions" aria-label="Conversation" tabindex="0"></div>
<div class="chat-dock">
<p class="chips-label" data-ask="chips-label">Suggested</p>
<div class="chips" data-ask="suggest" role="group" aria-label="Suggested questions"></div>
</div>
</section>
.chat { grid-column: 2; grid-row: 1 / span 2; height: calc(100svh - 96px - 28px); min-height: 460px; display: flex; flex-direction: column;
background: var(--glass); border: 1px solid var(--glass-line); border-radius: var(--radius);
-webkit-backdrop-filter: blur(26px) saturate(1.5); backdrop-filter: blur(26px) saturate(1.5);
box-shadow: 0 40px 90px -30px rgba(15, 30, 50, .45), inset 0 1px 0 rgba(255, 255, 255, .5); }
The page carries the dataset as JSON (the questions, chips, keywords and follow-ups; the answers stay in the HTML). A first visit opens on a showcase conversation: the greeting, "How far does one charge take me?" and its answer.
const D = JSON.parse($('#faq-data').textContent);
const BY = new Map(D.faq.map((f) => [f.id, f]));
const TOPIC = new Map(D.topics.map((t) => [t.id, t.label]));
// open on the showcase conversation from the concept (the range question, answered)
const SHOWCASE = 'battery-range';
const showcase = () => [{ r: 'b', k: 'greet' }, { r: 'u', t: BY.get(SHOWCASE).q, q: SHOWCASE }, { r: 'b', k: 'ans', q: SHOWCASE }];
state.messages = showcase();
{ const lastAns = [...state.messages].reverse().find((m) => m.k === 'ans'); if (lastAns) state.followups = BY.get(lastAns.q).followups; }
This is the one idea that keeps the chat simple: answers are not built in JavaScript. cloneAnswer() copies
the answer's markup from the accordion and renumbers its ids, so the list and the chat share one renderer.
function cloneAnswer(id) {
const src = $(`#faq-${id} .ans`);
const node = src.cloneNode(true);
for (const el of $$('[id]', node)) { const n = `${el.id.replace(/^rng-acc-.*/, 'rng')}-${++state.uid}`; $$(`[for="${el.id}"]`, node).forEach((l) => l.setAttribute('for', n)); el.id = n; }
return node;
}
function render(m, animate) {
const el = document.createElement('div');
el.className = `msg msg--${m.r === 'u' ? 'user' : 'bot'}` + (animate ? ' is-new' : '');
el.dataset.k = m.k || 'user';
if (m.r === 'u') {
el.innerHTML = `<div class="bubble bubble--user"><p>${esc(m.t)}</p></div>`;
return el;
}
let body = '';
if (m.k === 'greet') {
body = `<div class="bubble"><p>${esc(D.greeting)}</p><p class="bubble-sub">Popular right now</p>` +
`<div class="fu fu--greet">${GREET_PICKS.map((id) => `<button type="button" class="chip chip--q" data-ask-q="${id}">${esc(BY.get(id).q)}</button>`).join('')}</div></div>`;
} else if (m.k === 'ans') {
const f = BY.get(m.q);
el.innerHTML = `${avatar()}<div class="bubble bubble--card"><p class="bubble-tag">VOLTA · ${esc(TOPIC.get(f.topic))}</p></div>`;
const card = $('.bubble', el);
card.append(cloneAnswer(m.q));
wireRange(card);
return el;
}
el.innerHTML = avatar() + body;
return el;
}
The range answer is the rich one. Its slider picks the battery (three packs: 500, 625 and 750 Wh), and the Eco, Tour
and Turbo numbers follow, 96 / 72 / 48 km up to 144 / 108 / 72 km. Each number drops in over 240 ms, the track fills up
to the thumb through --p, and aria-valuetext says "Long Range, 750 watt-hours".
function wireRange(scope) {
for (const w of $$('[data-widget=range]', scope)) {
if (w.dataset.wired) continue; w.dataset.wired = '1';
const packs = JSON.parse(w.dataset.packs), input = $('.range-input', w), out = $('.range-pack', w);
const set = (i, user) => {
const p = packs[i];
for (const b of $$('[data-mode]', w)) {
b.textContent = p[b.dataset.mode];
if (user && !reduced.matches) b.animate([{ transform: 'translateY(-35%)', opacity: 0.2 }, { transform: 'none', opacity: 1 }], { duration: 240, easing: 'cubic-bezier(.2,.8,.2,1)' });
}
out.textContent = `${p.name} ${p.wh} Wh`;
input.setAttribute('aria-valuetext', `${p.name}, ${p.wh} watt-hours`);
input.style.setProperty('--p', `${(i / (packs.length - 1)) * 100}%`);
w.dataset.pack = i;
};
input.addEventListener('input', () => set(+input.value, true));
}
}
/* range input: track filled up to the thumb with --p */
.range-input { -webkit-appearance: none; appearance: none; display: block; width: 100%; height: 30px; margin: 0; background: transparent; cursor: pointer; touch-action: pan-y; }
.range-input::-webkit-slider-runnable-track { height: var(--track-h, 6px); border-radius: 99px; background: var(--track-fill) 0 0 / var(--p, 50%) 100% no-repeat, var(--track-off); }
The chips show the last answer's follow-ups (or a topic's questions). A chip posts its question as your bubble and the answer under it; the log scrolls so that your question sits at the top, with its answer under it.
function renderSuggest() {
for (const box of els.suggest) {
let ids, label;
if (state.followups) { ids = state.followups; label = 'Related questions'; }
else { ids = topicIds(state.topic); label = `${TABS.find((t) => t.id === state.topic).label} questions`; }
const focusedId = box.contains(document.activeElement) ? document.activeElement.dataset.askQ : null;
box.innerHTML = ids.map((id) => chipHTML(id)).join('');
box.setAttribute('aria-label', label);
if (els.chipsLabel) els.chipsLabel.textContent = label;
if (focusedId !== null) ($(`[data-ask-q="${focusedId}"]`, box) || $('.chip', box)).focus({ preventScroll: true });
}
}
function scrollLog(el, smooth) {
const log = els.log;
const behavior = smooth && !reduced.matches ? 'smooth' : 'auto';
let top = log.scrollHeight - log.clientHeight;
if (el && el.classList.contains('msg--bot')) {
const prev = el.previousElementSibling;
const anchor = prev && prev.classList.contains('msg--user') ? prev : el;
top = Math.min(top, anchor.offsetTop - 12);
}
log.scrollTo({ top, behavior });
}
function push(m, animate, scroll = true) {
state.messages.push(m);
const el = render(m, animate);
els.log.append(el);
if (scroll) scrollLog(el, animate);
afterMessage(m);
return el;
}
function ask(id, text) {
const f = BY.get(id); if (!f) return;
push({ r: 'u', t: text || f.q, q: id }, true, false);
push({ r: 'b', k: 'ans', q: id }, true);
}

3 · Typing dots and streamed words
An instant answer feels like a page, not a person. So a reply waits behind three typing dots, longer for a longer answer: 420 ms plus 1.1 ms a character, between 600 and 1,100 ms.
function typingDelay(m) {
if (reduced.matches) return 180 * state.speed;
const len = m.k === 'ans' ? BY.get(m.q).text.length : 80;
return Math.min(1100, Math.max(600, 420 + len * 1.1)) * state.speed;
}
The wait is put to work. While the dots run, the reply is built invisibly, out of the flow, and its blocks go into the page one per frame, so no single frame lays out a whole rich card. Replies queue on one promise, so two quick taps never overlap.
function explode(el) {
// detach the bubble's blocks (going one level into the answer and the range card); return them in insertion order
const ops = [];
const walk = (node) => { for (const c of [...node.children]) { ops.push([node, c]); if (c.matches('.bubble, .ans, .range')) walk(c); } };
walk(el);
for (const [, c] of [...ops].reverse()) c.remove();
return ops;
}
function reply(m) {
state.busy = state.busy.then(async () => {
const t0 = performance.now();
const t = document.createElement('div');
t.className = 'msg msg--bot msg--typing is-new'; t.setAttribute('aria-hidden', 'true');
t.innerHTML = `${avatar()}<div class="bubble typing"><i></i><i></i><i></i></div>`;
els.log.append(t); scrollLog(null, true); say('VOLTA is typing');
await nextFrame(); await nextFrame();
// while the dots run, build the reply and let it lay out, invisible and out of the flow (aria-hidden until shown)
// the blocks go in one per frame, so no single frame pays for laying out a whole rich card
const el = render(m, false, true);
el.classList.add('is-pending'); el.setAttribute('aria-hidden', 'true');
const ops = explode(el);
els.log.append(el);
const until = t0 + typingDelay(m);
while (ops.length && performance.now() < until - 20) { const [parent, child] = ops.shift(); parent.append(child); await nextFrame(); }
for (const [parent, child] of ops) parent.append(child);
await sleep(Math.max(0, until - performance.now()));
t.remove();
el.classList.remove('is-pending'); el.removeAttribute('aria-hidden'); el.classList.add('is-new');
state.messages.push(m);
scrollLog(el, true);
afterMessage(m);
settleMessage(el);
}).catch((e) => console.warn(e));
return state.busy;
}
Then the words stream in. Every text node is cut into spans of three words, each fading in a step later (at most 70 ms, so the text takes about a second); the rich blocks rise in after the last word.
function streamWords(scope) {
// words fade in one after another (opacity only); rich blocks rise in after the text
let i = 0;
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT, {
acceptNode: (n) => (n.parentElement.closest('.range, .ans-table-wrap, .ans-bike, .ans-btn, .fb, .fu, .stream-skip') || !n.nodeValue.trim()
? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
});
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
const total = nodes.reduce((s, n) => s + n.nodeValue.split(/\s+/).filter(Boolean).length, 0);
const step = Math.min(70, 1000 / Math.max(1, Math.ceil(total / 3)));
for (const n of nodes) {
// three words per span: the stream still reads word by word, with a third of the animated elements
const frag = document.createDocumentFragment();
const words = n.nodeValue.split(/(\s+)/).filter(Boolean);
let buf = '', count = 0;
const flush = () => { if (!buf) return; const sp = document.createElement('span'); sp.className = 'w'; sp.style.setProperty('--i', i++); sp.textContent = buf; frag.append(sp); buf = ''; count = 0; };
for (const part of words) {
if (/^\s+$/.test(part)) { if (count >= 3) { flush(); frag.append(part); } else buf += part; continue; }
buf += part; count++;
}
flush();
n.replaceWith(frag);
}
const host = scope.closest('.bubble') || scope;
host.style.setProperty('--step', `${step}ms`);
host.style.setProperty('--after', `${Math.round(i * step + 60)}ms`);
}
/* streamed answer: words fade in one after another, then the rich blocks rise in */
.msg.is-pending { position: absolute; left: var(--pad-l, 0); right: var(--pad-r, 0); visibility: hidden; pointer-events: none; }
.is-new .w { animation: w-in .3s ease-out both; animation-delay: calc(var(--i) * var(--step, 24ms)); }
@keyframes w-in { from { opacity: 0; } }
.is-new .ans > :is(.range, .ans-table-wrap, .ans-bike, .ans-btn, .ans-list), .is-new .fu:not(.fu--greet) {
animation: rise .42s cubic-bezier(.2, .8, .2, 1) both; animation-delay: var(--after, 0ms);
}
.is-new .ans > .ans-list { animation-name: w-in; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
/* typing indicator */
.typing { display: flex; gap: 5px; align-items: center; }
.typing i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .35; animation: dot 1.05s ease-in-out infinite; }
.typing i:nth-child(2) { animation-delay: .15s; }
.typing i:nth-child(3) { animation-delay: .3s; }
@keyframes dot { 0%, 60%, 100% { transform: translateY(0); opacity: .3; } 30% { transform: translateY(-4px); opacity: 1; } }
Finished animations would stay in every style pass as the conversation grows, so 700 ms after the last word a message drops its animations and the spans are unwrapped back into plain text.
// once a message has finished arriving, drop its entry animations and unwrap the word spans, so finished animations
// don't stay in every style pass as the conversation grows
function settleMessage(el) {
const host = $('.bubble', el), after = parseFloat(host?.style.getPropertyValue('--after')) || 0;
setTimeout(() => later(() => {
el.classList.remove('is-new');
const spans = $$('.w', el);
if (spans.length) { const parents = new Set(spans.map((w) => w.parentNode)); for (const w of spans) w.replaceWith(w.textContent); parents.forEach((p) => p.normalize()); }
}), after + 700);
}
With reduced motion, a reply waits 180 ms and nothing animates.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-delay: 0ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
.typing i { opacity: .6; }
}

4 · Free text: the composer and the matcher
A composer under the chips sends your own words.
<form class="composer" data-ask="composer" autocomplete="off">
<label class="vh" for="ask-input">Ask a question</label>
<input id="ask-input" name="q" type="text" placeholder="Type a message…" enterkeyhint="send" maxlength="240">
<button class="send" type="submit"><span class="vh">Send</span><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21.5 2.5 10.6 13.4"/><path d="m21.5 2.5-7 19-3.9-8.1-8.1-3.9z"/></svg></button>
</form>
els.composer.addEventListener('submit', (e) => {
e.preventDefault();
const v = els.input.value; els.input.value = '';
submitText(v);
});
submitText() tries the easy intents first ("thanks", "hi"), then asks the matcher: a confident match is
answered like a chip, a vague one gets the three closest questions as chips, and nothing found says so.
function submitText(raw) {
const text = raw.trim().replace(/\s+/g, ' ');
if (!text) return;
push({ r: 'u', t: text }, true, false);
if (intents.thanks(text)) return reply({ r: 'b', k: 'say', t: "You're welcome. Enjoy the ride!" });
if (intents.hello(text)) return reply({ r: 'b', k: 'say', t: 'Hi! What would you like to know? Pick a topic below, or just type your question.' });
const r = matcher.match(text);
if (r.confident) return reply({ r: 'b', k: 'ans', q: r.best, via: 'text' });
if (r.candidates.length) return reply({ r: 'b', k: 'sug', t: text, ids: r.candidates.map((c) => c.id) });
return reply({ r: 'b', k: 'none', t: text });
}
const HUMAN = /\b(human|person|agent|someone|somebody|representative|operator|call me|talk to|speak to|real person|support team|contact)\b/i;
const THANKS = /^\s*(thanks|thank you|thx|cheers|great|perfect|awesome|ok(ay)?|cool)\b[\s!.]*$/i;
const HELLO = /^\s*(hi|hello|hey|hiya|good (morning|afternoon|evening)|yo)\b[\s!.]*$/i;
match.js is small. Words are lower-cased, stop words dropped ("one", "bike" and "volta" too, since nearly
every question has them), and each word is stemmed and mapped through about 120 synonyms, so "shipping", "courier" and
"arrive" all become deliver.
export function stem(w) {
w = w.toLowerCase().replace(/[’']s$/, '');
if (SYN[w]) return SYN[w];
if (w.length > 5 && w.endsWith('ing')) w = w.slice(0, -3);
else if (w.length > 4 && w.endsWith('ies')) w = w.slice(0, -3) + 'y';
else if (w.length > 4 && w.endsWith('ed')) w = w.slice(0, -2);
else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss')) w = w.slice(0, -1);
return SYN[w] || w;
}
export function tokens(text) {
return (text.toLowerCase().match(/[a-z0-9/]+(?:'[a-z]+)?/g) || [])
.filter((w) => !STOP.has(w) && w.length > 1).map(stem);
}
Each question is indexed with weights by field (question 3, chip 2.5, keywords 2, answer text 0.6), and every word by how rare it is (IDF), so "warranty" counts for more than "long".
// per entry: token → weight (question 3, chip 2.5, keywords 2, answer text 0.6)
const docs = faq.map((f) => {
const w = new Map();
const add = (text, k) => { for (const t of tokens(text)) w.set(t, Math.max(w.get(t) || 0, k)); };
add(f.text, 0.6); add(f.keywords, 2); add(f.chip, 2.5); add(f.q, 3);
return { id: f.id, w };
});
const df = new Map();
for (const d of docs) for (const t of d.w.keys()) df.set(t, (df.get(t) || 0) + 1);
const N = docs.length;
const idf = (t) => Math.log(1 + N / (df.get(t) || N));
A word the index does not know is taken as a typo of the closest known word: one letter off for words of 4 to 6 letters, two for longer ones, and counting less for it (0.8 and 0.6). So "waranty claim" still finds the claim.
function resolve(t) { // unknown word → the closest known one (typos), or nothing
if (df.has(t)) return [t, 1];
if (t.length < 4) return [null, 0];
const max = t.length >= 7 ? 2 : 1;
let best = null, bd = max + 1;
for (const v of vocab) { if (Math.abs(v.length - t.length) > max) continue; const d = lev(t, v, max); if (d < bd) { bd = d; best = v; } }
return best ? [best, bd === 1 ? 0.8 : 0.6] : [null, 0];
}
A match is confident when it scores at least 3.2 and beats the next one by 18 % (or scores 7.5 or more). The candidates are those scoring 0.9 or more, three at most.
function match(text) {
const q = [...new Set(tokens(text))].map(resolve).filter(([t]) => t);
if (!q.length) return { best: null, score: 0, confident: false, candidates: [] };
const scored = docs.map((d) => {
let s = 0;
for (const [t, k] of q) { const w = d.w.get(t); if (w) s += w * idf(t) * k; }
return { id: d.id, score: s / Math.sqrt(q.length) };
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score);
const top = scored[0];
if (!top) return { best: null, score: 0, confident: false, candidates: [] };
const second = scored[1]?.score || 0;
const confident = top.score >= 3.2 && (top.score - second >= 0.18 * top.score || top.score >= 7.5);
return { best: top.id, score: top.score, confident, candidates: scored.filter((x) => x.score >= 0.9).slice(0, 3) };
}

5 · Topic tabs and the search
Six topic tabs pick which questions the chips offer. They are a real tablist: one tab in the tab order
(a roving tabindex), arrow keys, Home and End.
function renderTabs() {
for (const list of els.tabs) {
list.innerHTML = TABS.map((t) => `<button type="button" role="tab" class="tab" data-topic="${t.id}" aria-selected="${t.id === state.topic}" tabindex="${t.id === state.topic ? 0 : -1}">${esc(t.label)}</button>`).join('');
const box = els.suggest[0];
if (box) { box.id ||= `suggest-${SKIN}`; $$('[role=tab]', list).forEach((b) => b.setAttribute('aria-controls', box.id)); }
}
}
function setTopic(id, focus) {
state.topic = id; state.followups = null;
for (const list of els.tabs) for (const b of $$('[role=tab]', list)) {
const on = b.dataset.topic === id; b.setAttribute('aria-selected', on); b.tabIndex = on ? 0 : -1;
if (on && focus && list.contains(document.activeElement)) b.focus();
}
renderSuggest();
}
for (const list of els.tabs) {
list.innerHTML = TABS.map((t) => `<button type="button" role="tab" class="tab" data-topic="${t.id}" aria-selected="${t.id === state.topic}" tabindex="${t.id === state.topic ? 0 : -1}">${esc(t.label)}</button>`).join('');
const box = els.suggest[0];
if (box) { box.id ||= `suggest-${SKIN}`; $$('[role=tab]', list).forEach((b) => b.setAttribute('aria-controls', box.id)); }
}
}
function setTopic(id, focus) {
state.topic = id; state.followups = null;
for (const list of els.tabs) for (const b of $$('[role=tab]', list)) {
const on = b.dataset.topic === id; b.setAttribute('aria-selected', on); b.tabIndex = on ? 0 : -1;
if (on && focus && list.contains(document.activeElement)) b.focus();
}
renderSuggest();
}
// ---------- the search comboboxes (several on a page, kept in sync)
function setQuery(q, from) {
state.query = q.trim();
for (const s of els.searches) {
const input = $('input', s);
if (input !== from || !q) input.value = q;
$('.search-clear', s).hidden = !q;
}
renderSuggest(); renderResults(from);
}
function renderResults(input) {
for (const s of els.searches) {
const inp = $('input', s), ul = $('.results', s);
const show = inp === input && state.query && document.activeElement === inp;
if (!show) { ul.hidden = true; inp.setAttribute('aria-expanded', 'false'); inp.removeAttribute('aria-activedescendant'); continue; }
const { ids, fuzzy } = searchIds(state.query, 6);
const opts = ids.map((id, i) => `<li role="option" id="${ul.id}-o${i}" class="opt" data-ask-q="${id}" aria-selected="false">` +
`<span class="opt-q">${fuzzy ? esc(BY.get(id).q) : matchedLabel(BY.get(id), state.query)}</span><span class="opt-t">${esc(TOPIC.get(BY.get(id).topic))}</span></li>`);
opts.push(`<li role="option" id="${ul.id}-o${ids.length}" class="opt opt--ask" data-ask-text="${esc(state.query)}" aria-selected="false">` +
`<span class="opt-q">Ask “${esc(state.query)}” in the chat</span>${ICON.arrow}</li>`);
ul.innerHTML = (fuzzy && ids.length ? '<li class="opt-head" role="presentation">No exact match. Closest questions:</li>' : '') + opts.join('');
ul.hidden = false; inp.setAttribute('aria-expanded', 'true'); inp.removeAttribute('aria-activedescendant');
say(ids.length ? `${ids.length} question${ids.length > 1 ? 's' : ''} found` : 'No question found. Press Enter to ask in the chat.');
}
}
function moveActive(input, dir) {
const ul = $(`#${input.getAttribute('aria-controls')}`); if (ul.hidden) return;
const opts = $$('[role=option]', ul); if (!opts.length) return;
let i = opts.findIndex((o) => o.getAttribute('aria-selected') === 'true');
i = i === -1 ? (dir > 0 ? 0 : opts.length - 1) : (i + dir + opts.length) % opts.length;
opts.forEach((o, k) => o.setAttribute('aria-selected', k === i));
input.setAttribute('aria-activedescendant', opts[i].id); opts[i].scrollIntoView({ block: 'nearest' });
}
function chooseOption(opt) {
if (!opt) return;
if (opt.dataset.askQ) ask(opt.dataset.askQ);
else if (opt.dataset.askText) submitText(opt.dataset.askText);
setQuery('', null);
}
// ---------- messages
const avatar = () => '<span class="avatar" aria-hidden="true">V</span>';
function cloneAnswer(id) {
const src = $(`#faq-${id} .ans`);
const node = src.cloneNode(true);
for (const el of $$('[id]', node)) { const n = `${el.id.replace(/^rng-acc-.*/, 'rng')}-${++state.uid}`; $$(`[for="${el.id}"]`, node).forEach((l) => l.setAttribute('for', n)); el.id = n; }
return node;
}
function wireRange(scope) {
for (const w of $$('[data-widget=range]', scope)) {
if (w.dataset.wired) continue; w.dataset.wired = '1';
const packs = JSON.parse(w.dataset.packs), input = $('.range-input', w), out = $('.range-pack', w);
const set = (i, user) => {
const p = packs[i];
for (const b of $$('[data-mode]', w)) {
b.textContent = p[b.dataset.mode];
if (user && !reduced.matches) b.animate([{ transform: 'translateY(-35%)', opacity: 0.2 }, { transform: 'none', opacity: 1 }], { duration: 240, easing: 'cubic-bezier(.2,.8,.2,1)' });
}
out.textContent = `${p.name} ${p.wh} Wh`;
input.setAttribute('aria-valuetext', `${p.name}, ${p.wh} watt-hours`);
input.style.setProperty('--p', `${(i / (packs.length - 1)) * 100}%`);
w.dataset.pack = i;
};
input.addEventListener('input', () => set(+input.value, true));
}
}
function streamWords(scope) {
// words fade in one after another (opacity only); rich blocks rise in after the text
let i = 0;
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT, {
acceptNode: (n) => (n.parentElement.closest('.range, .ans-table-wrap, .ans-bike, .ans-btn, .fb, .fu, .stream-skip') || !n.nodeValue.trim()
? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
});
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
const total = nodes.reduce((s, n) => s + n.nodeValue.split(/\s+/).filter(Boolean).length, 0);
const step = Math.min(70, 1000 / Math.max(1, Math.ceil(total / 3)));
for (const n of nodes) {
// three words per span: the stream still reads word by word, with a third of the animated elements
const frag = document.createDocumentFragment();
const words = n.nodeValue.split(/(\s+)/).filter(Boolean);
let buf = '', count = 0;
const flush = () => { if (!buf) return; const sp = document.createElement('span'); sp.className = 'w'; sp.style.setProperty('--i', i++); sp.textContent = buf; frag.append(sp); buf = ''; count = 0; };
for (const part of words) {
if (/^\s+$/.test(part)) { if (count >= 3) { flush(); frag.append(part); } else buf += part; continue; }
buf += part; count++;
}
flush();
n.replaceWith(frag);
}
const host = scope.closest('.bubble') || scope;
host.style.setProperty('--step', `${step}ms`);
host.style.setProperty('--after', `${Math.round(i * step + 60)}ms`);
}
function render(m, animate, stream = animate) {
const el = document.createElement('div');
el.className = `msg msg--${m.r === 'u' ? 'user' : 'bot'}` + (animate ? ' is-new' : '');
el.dataset.k = m.k || 'user';
if (m.r === 'u') {
el.innerHTML = `<div class="bubble bubble--user"><p>${esc(m.t)}</p></div>`;
return el;
}
let body = '';
if (m.k === 'greet') {
body = `<div class="bubble"><p class="stream">${esc(D.greeting)}</p><p class="bubble-sub stream-skip">Popular right now</p>` +
`<div class="fu fu--greet">${GREET_PICKS.map((id) => `<button type="button" class="chip chip--q" data-ask-q="${id}">${esc(BY.get(id).q)}</button>`).join('')}</div></div>`;
} else if (m.k === 'ans') {
const f = BY.get(m.q);
el.innerHTML = `${avatar()}<div class="bubble bubble--card"><p class="bubble-tag">VOLTA · ${esc(TOPIC.get(f.topic))}</p></div>`;
const card = $('.bubble', el);
card.append(cloneAnswer(m.q));
wireRange(card);
if (stream) streamWords($('.ans', card));
return el;
} else if (m.k === 'sug') {
body = `<div class="bubble"><p class="stream">I'm not sure I understood “${esc(m.t)}”. Did you mean one of these?</p>` +
`<div class="fu">${m.ids.map((id) => `<button type="button" class="chip chip--q" data-ask-q="${id}">${esc(BY.get(id).q)}</button>`).join('')}</div></div>`;
} else if (m.k === 'none') {
body = `<div class="bubble"><p class="stream">I don't have an answer to “${esc(m.t)}” yet.</p></div>`;
} else if (m.k === 'say') {
body = `<div class="bubble"><p class="stream">${esc(m.t)}</p></div>`;
}
el.innerHTML = avatar() + body;
if (stream) $$('.stream', el).forEach(streamWords);
return el;
}
function scrollLog(el, smooth) {
const log = els.log;
const behavior = smooth && !reduced.matches ? 'smooth' : 'auto';
let top = log.scrollHeight - log.clientHeight;
if (el && el.classList.contains('msg--bot')) {
const prev = el.previousElementSibling;
const anchor = prev && prev.classList.contains('msg--user') ? prev : el;
top = Math.min(top, anchor.offsetTop - 12);
}
log.scrollTo({ top, behavior });
}
// once a message has finished arriving, drop its entry animations and unwrap the word spans, so finished animations
// don't stay in every style pass as the conversation grows
function settleMessage(el) {
const host = $('.bubble', el), after = parseFloat(host?.style.getPropertyValue('--after')) || 0;
setTimeout(() => later(() => {
el.classList.remove('is-new');
const spans = $$('.w', el);
if (spans.length) { const parents = new Set(spans.map((w) => w.parentNode)); for (const w of spans) w.replaceWith(w.textContent); parents.forEach((p) => p.normalize()); }
}), after + 700);
}
const later = (fn) => (window.requestIdleCallback ? requestIdleCallback(fn, { timeout: 400 }) : setTimeout(fn, 60));
const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()));
function afterMessage(m) {
if (m.k === 'ans') { state.followups = BY.get(m.q).followups; if (!state.query) later(renderSuggest); }
}
function push(m, animate, scroll = true) {
state.messages.push(m);
const el = render(m, animate);
els.log.append(el);
if (scroll) scrollLog(el, animate);
afterMessage(m);
if (animate) settleMessage(el);
return el;
}
function typingDelay(m) {
if (reduced.matches) return 180 * state.speed;
const len = m.k === 'ans' ? BY.get(m.q).text.length : 80;
return Math.min(1100, Math.max(600, 420 + len * 1.1)) * state.speed;
}
function explode(el) {
// detach the bubble's blocks (going one level into the answer and the range card); return them in insertion order
const ops = [];
const walk = (node) => { for (const c of [...node.children]) { ops.push([node, c]); if (c.matches('.bubble, .ans, .range')) walk(c); } };
walk(el);
for (const [, c] of [...ops].reverse()) c.remove();
return ops;
}
function reply(m) {
state.busy = state.busy.then(async () => {
const t0 = performance.now();
const t = document.createElement('div');
t.className = 'msg msg--bot msg--typing is-new'; t.setAttribute('aria-hidden', 'true');
t.innerHTML = `${avatar()}<div class="bubble typing"><i></i><i></i><i></i></div>`;
els.log.append(t); scrollLog(null, true); say('VOLTA is typing');
await nextFrame(); await nextFrame();
// while the dots run, build the reply and let it lay out, invisible and out of the flow (aria-hidden until shown)
// the blocks go in one per frame, so no single frame pays for laying out a whole rich card
const el = render(m, false, true);
el.classList.add('is-pending'); el.setAttribute('aria-hidden', 'true');
const ops = explode(el);
els.log.append(el);
const until = t0 + typingDelay(m);
while (ops.length && performance.now() < until - 20) { const [parent, child] = ops.shift(); parent.append(child); await nextFrame(); }
for (const [parent, child] of ops) parent.append(child);
await sleep(Math.max(0, until - performance.now()));
t.remove();
el.classList.remove('is-pending'); el.removeAttribute('aria-hidden'); el.classList.add('is-new');
state.messages.push(m);
scrollLog(el, true);
afterMessage(m);
settleMessage(el);
}).catch((e) => console.warn(e));
return state.busy;
}
function ask(id, text) {
const f = BY.get(id); if (!f) return;
push({ r: 'u', t: text || f.q, q: id }, true, false);
return reply({ r: 'b', k: 'ans', q: id });
}
function submitText(raw) {
const text = raw.trim().replace(/\s+/g, ' ');
if (!text) return;
push({ r: 'u', t: text }, true, false);
if (intents.thanks(text)) return reply({ r: 'b', k: 'say', t: "You're welcome. Enjoy the ride!" });
if (intents.hello(text)) return reply({ r: 'b', k: 'say', t: 'Hi! What would you like to know? Pick a topic below, or just type your question.' });
const r = matcher.match(text);
if (r.confident) return reply({ r: 'b', k: 'ans', q: r.best, via: 'text' });
if (r.candidates.length) return reply({ r: 'b', k: 'sug', t: text, ids: r.candidates.map((c) => c.id) });
return reply({ r: 'b', k: 'none', t: text });
}
const actions = {
'clear-search': (el) => { const i = $('input', el.closest('.search')); setQuery('', i); i.focus(); },
};
// ---------- events (delegated)
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-action], [data-ask-q], [role=tab], [data-ask-text]');
if (!t) return;
if (t.matches('[role=tab]')) { setTopic(t.dataset.topic, true); return; }
if (t.matches('[role=option]')) { e.preventDefault(); chooseOption(t); return; }
if (t.dataset.askQ) {
e.preventDefault();
ask(t.dataset.askQ); return;
}
if (t.dataset.action) {
e.preventDefault();
actions[t.dataset.action]?.(t);
}
});
els.composer.addEventListener('submit', (e) => {
e.preventDefault();
const v = els.input.value; els.input.value = '';
submitText(v);
});
for (const s of els.searches) {
const input = $('input', s);
input.addEventListener('input', () => setQuery(input.value, input));
input.addEventListener('focus', () => state.query && renderResults(input));
input.addEventListener('blur', () => setTimeout(() => { if (!s.contains(document.activeElement)) { $('.results', s).hidden = true; input.setAttribute('aria-expanded', 'false'); } }, 120));
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); if ($('.results', s).hidden && state.query) renderResults(input); moveActive(input, 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(input, -1); }
else if (e.key === 'Enter') {
e.preventDefault();
const id = input.getAttribute('aria-activedescendant');
if (id) chooseOption($(`#${id}`));
else if (state.query) { const q = state.query; setQuery('', null); input.value = ''; submitText(q); }
} else if (e.key === 'Escape') {
if (input.getAttribute('aria-expanded') === 'true') { e.preventDefault(); $('.results', s).hidden = true; input.setAttribute('aria-expanded', 'false'); }
else if (input.value) { e.preventDefault(); input.value = ''; setQuery('', input); }
}
});
$('.results', s).addEventListener('mousedown', (e) => e.preventDefault()); // keep focus in the input while clicking an option
}
for (const list of els.tabs) {
list.addEventListener('keydown', (e) => {
const tabs = $$('[role=tab]', list), i = tabs.indexOf(document.activeElement);
if (i === -1) return;
let j = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') j = 0; else if (e.key === 'End') j = tabs.length - 1;
if (j === null) return;
e.preventDefault(); tabs[j].focus(); setTopic(tabs[j].dataset.topic, true);
});
}
The search box is a WAI-ARIA combobox: an input with role="combobox" that owns a listbox.
<div class="search" data-ask="search" data-js-only data-fit>
<label class="vh" for="q-intro">Search questions</label>
<svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/></svg>
<input id="q-intro" type="search" placeholder="Search" autocomplete="off" spellcheck="false"
role="combobox" aria-expanded="false" aria-controls="res-intro" aria-autocomplete="list">
<button class="search-clear" type="button" data-action="clear-search" hidden><span class="vh">Clear search</span><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
<ul class="results" id="res-intro" role="listbox" aria-label="Matching questions" hidden></ul>
</div>
A question matches when every word you typed is in its question, chip, keywords or topic; matches in the question count more (3 at its start, 2 inside, 0.5 elsewhere). With no match at all, the free-text matcher lists the closest questions instead, so a typo still finds something.
function searchIds(query, limit = 8) {
const words = norm(query).split(/\s+/).filter(Boolean);
if (!words.length) return { ids: [], fuzzy: false };
const hits = [];
for (const f of D.faq) {
const q = norm(f.q), hay = norm(`${f.q} ${f.chip} ${f.keywords} ${TOPIC.get(f.topic)}`);
if (!words.every((w) => hay.includes(w))) continue;
let s = 0;
for (const w of words) s += q.includes(w) ? (q.startsWith(w) ? 3 : 2) : 0.5;
hits.push([f.id, s]);
}
if (hits.length) return { ids: hits.sort((a, b) => b[1] - a[1]).slice(0, limit).map((h) => h[0]), fuzzy: false };
return { ids: matcher.match(query).candidates.map((c) => c.id), fuzzy: true };
}
function highlight(text, query) {
const words = norm(query).split(/\s+/).filter((w) => w.length > 0);
if (!words.length) return esc(text);
const n = norm(text), marks = new Array(text.length).fill(false);
for (const w of words) { let i = n.indexOf(w); while (i !== -1) { for (let k = i; k < i + w.length; k++) marks[k] = true; i = n.indexOf(w, i + w.length); } }
let out = '', open = false;
for (let i = 0; i < text.length; i++) {
if (marks[i] && !open) { out += '<mark>'; open = true; }
if (!marks[i] && open) { out += '</mark>'; open = false; }
out += esc(text[i]);
}
return out + (open ? '</mark>' : '');
}
The listbox shows up to six questions with their topic, and its last option always asks your words in the chat. The count is announced in the live region.
function renderResults(input) {
for (const s of els.searches) {
const inp = $('input', s), ul = $('.results', s);
const show = inp === input && state.query && document.activeElement === inp;
if (!show) { ul.hidden = true; inp.setAttribute('aria-expanded', 'false'); inp.removeAttribute('aria-activedescendant'); continue; }
const { ids, fuzzy } = searchIds(state.query, 6);
const opts = ids.map((id, i) => `<li role="option" id="${ul.id}-o${i}" class="opt" data-ask-q="${id}" aria-selected="false">` +
`<span class="opt-q">${fuzzy ? esc(BY.get(id).q) : matchedLabel(BY.get(id), state.query)}</span><span class="opt-t">${esc(TOPIC.get(BY.get(id).topic))}</span></li>`);
opts.push(`<li role="option" id="${ul.id}-o${ids.length}" class="opt opt--ask" data-ask-text="${esc(state.query)}" aria-selected="false">` +
`<span class="opt-q">Ask “${esc(state.query)}” in the chat</span>${ICON.arrow}</li>`);
ul.innerHTML = (fuzzy && ids.length ? '<li class="opt-head" role="presentation">No exact match. Closest questions:</li>' : '') + opts.join('');
ul.hidden = false; inp.setAttribute('aria-expanded', 'true'); inp.removeAttribute('aria-activedescendant');
say(ids.length ? `${ids.length} question${ids.length > 1 ? 's' : ''} found` : 'No question found. Press Enter to ask in the chat.');
}
}
Arrow keys move aria-activedescendant (the focus stays in the input), Enter asks, and Escape first closes
the list, then clears the box.
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); if ($('.results', s).hidden && state.query) renderResults(input); moveActive(input, 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(input, -1); }
else if (e.key === 'Enter') {
e.preventDefault();
const id = input.getAttribute('aria-activedescendant');
if (id) chooseOption($(`#${id}`));
else if (state.query) { const q = state.query; setQuery('', null); input.value = ''; submitText(q); }
} else if (e.key === 'Escape') {
if (input.getAttribute('aria-expanded') === 'true') { e.preventDefault(); $('.results', s).hidden = true; input.setAttribute('aria-expanded', 'false'); }
else if (input.value) { e.preventDefault(); input.value = ''; setQuery('', input); }
}
});

6 · Feedback and a person
Every answer ends with two thumbs. Up says thanks; down says sorry and offers a person.
function fbHTML(qid) {
const v = state.feedback[qid];
if (v === 'up') return `<div class="fb fb--done" data-fb-for="${qid}"><span class="fb-ico">${ICON.check}</span><p>Thanks! Glad that helped.</p></div>`;
if (v === 'down') return `<div class="fb fb--done fb--down" data-fb-for="${qid}"><p>Sorry that missed. A person can sort it out.</p>` +
`<button type="button" class="fb-human" data-action="handoff" data-topic="${esc(TOPIC.get(BY.get(qid).topic))}">${ICON.headset}Talk to a person</button></div>`;
return `<div class="fb" role="group" aria-label="Was this answer helpful?" data-fb-for="${qid}"><p class="fb-q">Was this helpful?</p>` +
`<button type="button" class="fb-btn" data-fb="up" aria-pressed="false">${ICON.up}<span class="vh">Yes, helpful</span></button>` +
`<button type="button" class="fb-btn" data-fb="down" aria-pressed="false">${ICON.down}<span class="vh">Not helpful</span></button></div>`;
}
function giveFeedback(qid, v) {
state.feedback[qid] = v;
for (const fb of $$(`[data-fb-for="${qid}"]`, els.log)) {
const had = fb.contains(document.activeElement);
fb.outerHTML = fbHTML(qid);
if (had) { const n = $(`[data-fb-for="${qid}"]`, els.log); const b = $('button', n); (b || els.input).focus({ preventScroll: true }); }
}
say(v === 'up' ? 'Thanks for your feedback' : 'Thanks. You can talk to a person about this.');
}
"Talk to a person" can come from the chat's header, from an answer's own button ("Start a claim", "Book a pickup"), from a thumbs-down or from a question with no answer. It posts your request as a bubble, and VOLTA replies with a form. An open form is reused rather than stacked.
function openHandoff(topic, prefill, label) {
const last = [...$$('.msg[data-k=hand]', els.log)].pop();
const lm = [...state.messages].reverse().find((x) => x.k === 'hand');
if (last && lm && !lm.sent && !lm.cancelled) { const f = $('input', last); scrollLog(last, true); f && f.focus({ preventScroll: true }); return; }
push({ r: 'u', t: label || 'I’d like to talk to a person.' }, true, false);
return reply(handoffMsg(topic, prefill));
}
function handoffHTML(m) {
if (m.cancelled) return `<p>No problem. I'm here if you need anything else.</p>`;
if (m.sent) {
return `<div class="sent" tabindex="-1"><span class="sent-ico" aria-hidden="true">${ICON.check}</span><h3 class="sent-title">Message sent</h3>` +
`<p>Thanks, ${esc(m.sent.name)}. Someone from our rider team will reply to <b>${esc(m.sent.mail)}</b> within 4 working hours (Monday to Saturday, 8:00 to 20:00 CET).</p>` +
`<p class="sent-ref">Reference ${esc(m.sent.ref)}${m.sent.topic ? ` · ${esc(m.sent.topic)}` : ''}</p></div>`;
}
const n = ++state.uid;
return `<p class="bubble-tag">Rider team</p><h3 class="form-title" tabindex="-1">Talk to a person</h3>` +
`<p class="form-sub">Leave your details and a real person replies by email within 4 working hours.${m.topic ? ` Topic: <b>${esc(m.topic)}</b>.` : ''}</p>` +
`<form class="handoff" novalidate data-n="${n}">` +
`<div class="err-sum" role="alert" hidden></div>` +
`<div class="field"><label for="hn-${n}">Your name</label><input id="hn-${n}" name="name" autocomplete="name" required aria-describedby="hn-${n}-e"><p class="field-err" id="hn-${n}-e" hidden></p></div>` +
`<div class="field"><label for="he-${n}">Email</label><input id="he-${n}" name="email" type="email" inputmode="email" autocomplete="email" required aria-describedby="he-${n}-e"><p class="field-err" id="he-${n}-e" hidden></p></div>` +
`<div class="field"><label for="hm-${n}">How can we help?</label><textarea id="hm-${n}" name="message" rows="3" required aria-describedby="hm-${n}-e">${esc(m.prefill || '')}</textarea><p class="field-err" id="hm-${n}-e" hidden></p></div>` +
`<div class="form-actions"><button type="submit" class="btn-primary">Send to the team</button><button type="button" class="btn-ghost" data-cancel>Not now</button></div>` +
`</form>`;
}
The form checks itself only after the first try, then again on every blur and every change of a wrong field. Errors are
tied to their fields (aria-invalid, aria-describedby), a summary is read out
(role="alert"), and the focus goes to the first wrong field.
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function wireForm(el, m) {
const form = $('form', el); let tried = false;
const fields = {
name: (v) => (v.trim().length < 2 ? 'Enter your name.' : ''),
email: (v) => (!v.trim() ? 'Enter your email address.' : !EMAIL.test(v.trim()) ? 'Enter an email like name@example.com.' : ''),
message: (v) => (v.trim().length < 10 ? 'Tell us a little more: at least 10 characters.' : ''),
};
const check = (name) => {
const input = form.elements[name], err = fields[name](input.value), p = $(`#${input.id}-e`);
input.setAttribute('aria-invalid', err ? 'true' : 'false'); p.textContent = err; p.hidden = !err;
return err;
};
.field [aria-invalid=true] { border-color: var(--err); box-shadow: 0 0 0 1px var(--err); }
.field-err { font-size: 13px; color: var(--err); font-weight: 500; }
.err-sum { font-size: 13.5px; font-weight: 600; color: var(--err); background: color-mix(in srgb, var(--err) 10%, transparent); border-radius: 10px; padding: 9px 12px; }
Then "Sending…" for 900 ms and a sent state: the first name, a masked email such as a••@example.com and a
reference VLT- plus five digits. This is a mock: nothing leaves the browser, and the full email is never
stored.
form.addEventListener('submit', async (e) => {
e.preventDefault(); tried = true;
const bad = Object.keys(fields).filter((n) => check(n));
const sum = $('.err-sum', form);
if (bad.length) {
sum.hidden = false; sum.textContent = bad.length === 1 ? 'Please fix 1 field below.' : `Please fix ${bad.length} fields below.`;
form.elements[bad[0]].focus(); return;
}
sum.hidden = true;
const btn = $('.btn-primary', form); btn.disabled = true; btn.textContent = 'Sending…'; form.setAttribute('aria-busy', 'true');
await sleep((reduced.matches ? 300 : 900) * state.speed); // mock: nothing leaves the browser
const name = form.elements.name.value.trim().split(/\s+/)[0];
const mail = form.elements.email.value.trim();
const [u, d] = mail.split('@');
m.sent = { name, mail: `${u[0]}${'•'.repeat(Math.max(2, Math.min(6, u.length - 1)))}@${d}`, ref: `VLT-${String(Math.floor(10000 + Math.random() * 89999))}`, topic: m.topic };
$('.bubble', el).innerHTML = handoffHTML(m);
const s = $('.sent', el); s.focus({ preventScroll: true }); scrollLog(el, true);
});

7 · Memory, the list view, dark mode and New chat
The conversation now survives a reload. Every storage access is wrapped in try/catch, so with storage blocked (a private window, a preview) the chat simply forgets.
// ---------- storage (never throws: private windows, blocked storage, previews)
const KEY = (k) => `volta-ask:${SKIN}:${k}`;
const store = {
get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* storage unavailable: the chat still works */ } },
};
messages: store.get(KEY('history'), null),
feedback: store.get(KEY('feedback'), {}),
view: root.dataset.view === 'list' ? 'list' : 'chat',
The last 80 messages are saved, with the slider positions and the sent forms; a slider saves 250 ms after it stops.
const save = () => store.set(KEY('history'), state.messages.slice(-80));
let saveTimer = 0;
const saveSoon = () => { clearTimeout(saveTimer); saveTimer = setTimeout(save, 250); };
The top bar gets two buttons: dark mode and the list view. The page always opens light, as designed, and only a saved choice turns it dark, before the first paint.
(function(){var d=document.documentElement;d.classList.remove('nojs');d.classList.add('js');var t='light';try{t=localStorage.getItem('volta-ask:theme')==='dark'?'dark':'light';}catch(e){}d.dataset.theme=t;try{if(localStorage.getItem('volta-ask:lifestyle:view')==='list')d.dataset.view='list';}catch(e){}})();
function setTheme(t) {
root.dataset.theme = t;
try { localStorage.setItem('volta-ask:theme', t); } catch { /* ignore */ }
for (const b of $$('[data-action=theme]')) {
b.setAttribute('aria-pressed', t === 'dark');
}
$('meta[name=theme-color]')?.setAttribute('content', t === 'dark' ? '#11151b' : '#7fb2d6');
}
[data-theme=dark] {
--ink: #f2f3f5; --ink-2: #d4d7dc; --muted: #a3a9b1; --paper: #101215;
--glass: rgba(14, 16, 20, .62); --glass-line: rgba(255, 255, 255, .12); --glass-strong: rgba(30, 33, 38, .92);
--line: rgba(255, 255, 255, .1); --card: #1e2125; --card-shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 30px -14px rgba(0, 0, 0, .7);
--user: #3c4046; --user-ink: #fff;
--chip: rgba(38, 41, 46, .8); --chip-line: rgba(255, 255, 255, .12); --chip-hover: #33373d;
--btn: #f2f3f5; --btn-ink: #101113; --photo: #2a2d32;
--tab: rgba(20, 22, 26, .55); --tab-line: rgba(255, 255, 255, .18);
--mark: #8a6d12; --ring: #7aa2ff; --ok: #4ade80; --err: #ff7b7b;
--track-off: rgba(255, 255, 255, .16); --thumb-border: 3px solid #1e2125;
--scene-veil: linear-gradient(180deg, rgba(6, 9, 15, .58), rgba(6, 9, 15, .66));
--bike-frame: #7a8086; --bike-frame-hi: #b9bfc4; --bike-frame-lo: #43474b; --bike-tyre: #0d0e0f; --bike-metal: #9da3a8; --bike-dark: #16171a;
--bike-shadow: #000;
color-scheme: dark;
}
The list view is the accordion of stage 1 again, in place of the chat: all 24 questions, grouped. The search narrows it and highlights it, and a topic tab scrolls to its group.
function setView(v, focus = true) {
state.view = v; root.dataset.view = v; try { localStorage.setItem(KEY('view'), v); } catch { /* ignore */ }
for (const b of $$('[data-action=view]')) {
b.setAttribute('aria-pressed', v === 'list');
}
filterList();
if (focus) {
if (v === 'list') { const h = $('#faq-list-title'); h.tabIndex = -1; h.focus(); say('List view: all questions as an accordion'); }
else { els.input.focus(); say('Chat view'); }
}
}
function filterList() {
// the list shows every question, grouped by topic; a search narrows it (tabs jump to their group, see setTopic)
if (!els.list) return;
const q = state.query, words = norm(q).split(/\s+/).filter(Boolean);
const pick = q ? new Set(searchIds(q, 99).ids) : null;
let shown = 0;
for (const g of $$('.faq-group', els.list)) {
let n = 0;
for (const d of $$('.faq-item', g)) {
const f = BY.get(d.dataset.q);
const on = pick ? pick.has(f.id) : true;
d.hidden = !on; if (on) n++;
$('.faq-q', d).innerHTML = words.length && on ? matchedLabel(f, q) : esc(f.q);
}
g.hidden = !n; shown += n;
}
if (els.listEmpty) els.listEmpty.hidden = shown > 0;
}
function jumpToGroup(topic) {
if (state.view !== 'list' || !els.list) return;
const behavior = reduced.matches ? 'auto' : 'smooth';
const g = $(`.faq-group[data-topic="${topic}"]`, els.list);
if (!g || g.hidden) { els.list.scrollTo({ top: 0, behavior }); if (getComputedStyle(els.list).overflowY === 'visible') els.list.scrollIntoView({ block: 'start', behavior }); return; }
if (getComputedStyle(els.list).overflowY !== 'visible') els.list.scrollTo({ top: els.list.scrollTop + g.getBoundingClientRect().top - els.list.getBoundingClientRect().top - 12, behavior });
else g.scrollIntoView({ block: 'start', behavior });
}
function newChat() {
state.messages = [{ r: 'b', k: 'greet' }]; state.followups = null; save();
const g = render(state.messages[0], true); els.log.replaceChildren(g); els.log.scrollTop = 0; settleMessage(g);
renderSuggest(); ensureChat(); els.input.focus(); say('New chat started');
}

8 · The phone
Under 700 px wide (or 500 px tall) the chat becomes the whole screen, still frosted, over the photo.
.chat { position: fixed; inset: 0; z-index: 7; height: 100svh; height: 100dvh; min-height: 0; border-radius: 0; border: 0;
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
background: var(--glass); -webkit-backdrop-filter: blur(18px) saturate(1.4); backdrop-filter: blur(18px) saturate(1.4); box-shadow: none; }
A phone has no room for the intro's search and the header's buttons, so the chat's header gets a search button (a search row opens under it) and a ⋯ menu with New chat, Talk to a person, List view and Dark mode.
<div class="menu-wrap only-phone">
<button class="icon-btn" type="button" data-action="menu" aria-haspopup="menu" aria-expanded="false" aria-controls="chat-menu"><span class="vh">More options</span><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/></svg></button>
<div class="menu" id="chat-menu" role="menu" aria-label="Chat options" hidden>
<button type="button" role="menuitem" data-action="new"><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 0 1 15.5-6.3L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.3L3 16"/><path d="M3 21v-5h5"/></svg>New chat</button>
<button type="button" role="menuitem" data-action="handoff"><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 14v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="14" width="4" height="6" rx="1.5"/><rect x="17" y="14" width="4" height="6" rx="1.5"/><path d="M19 20a3 3 0 0 1-3 2h-3"/></svg>Talk to a person</button>
<button type="button" role="menuitem" data-action="view"><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 6h12M9 12h12M9 18h12"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/></svg>List view</button>
<button type="button" role="menuitem" data-action="theme"><svg class="i" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11z"/></svg><span data-theme-label>Dark mode</span></button>
</div>
</div>
function toggleMenu(btn, open) {
const menu = $(`#${btn.getAttribute('aria-controls')}`);
open = open ?? menu.hidden;
menu.hidden = !open; btn.setAttribute('aria-expanded', open);
if (open) $('[role^=menuitem]', menu).focus();
}
function closeMenus(except) {
for (const b of $$('[data-action=menu][aria-expanded=true]')) if (b !== except) toggleMenu(b, false);
}
document.addEventListener('keydown', (e) => {
const menu = e.target.closest?.('.menu');
if (menu) {
const items = $$('[role^=menuitem]', menu), i = items.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); items.at(-1).focus(); }
else if (e.key === 'Escape' || e.key === 'Tab') { const b = $(`[aria-controls="${menu.id}"]`); toggleMenu(b, false); if (e.key === 'Escape') b.focus(); }
} else if (e.key === 'Escape' && $$('[data-action=menu][aria-expanded=true]').length) closeMenus();
});
'search-toggle': (el) => {
const t = $(`#${el.getAttribute('aria-controls')}`), open = el.getAttribute('aria-expanded') !== 'true';
el.setAttribute('aria-expanded', open); t.hidden = !open;
if (open) $('input[type=search]', t)?.focus(); else { setQuery('', null); el.focus(); }
},
✕ minimises the chat: the FAQ shows over the photo, and an "Ask VOLTA" launcher brings the chat back.
function openPanel(focus = true) {
delete root.dataset.chat; els.launcher && (els.launcher.hidden = true);
if (focus) els.input.focus();
}
function closePanel() {
root.dataset.chat = 'closed'; if (els.launcher) { els.launcher.hidden = false; els.launcher.focus(); }
}
.launcher { position: fixed; right: clamp(20px, 5.5vw, 96px); bottom: 28px; z-index: 8; height: 58px; padding: 0 22px 0 10px; border-radius: 99px; background: #0e0f10; color: #fff; display: inline-flex; align-items: center; gap: 12px; font-weight: 600; font-size: 16px; box-shadow: 0 20px 40px -14px rgba(0, 0, 0, .55); }
.launcher .avatar { background: #fff; color: #0e0f10; box-shadow: none; }
[data-chat=closed] .chat { display: none; }
Last, a silent warm-up. The first question would otherwise pay for compiling the render paths and laying out a rich card for the first time. So once the page is idle, it renders a question, the range answer and a "did you mean" reply invisibly, lays them out and throws them away.
// silent warm-up: the first question otherwise pays for compiling the render paths and the first layout of a rich card.
// Render a question, the dots and the range answer invisibly in the log, lay them out, throw them away.
later(() => {
const box = document.createElement('div');
box.className = 'msg is-pending'; box.setAttribute('aria-hidden', 'true'); box.style.flexDirection = 'column';
box.append(render({ r: 'u', t: 'warm-up' }, true), render({ r: 'b', k: 'ans', q: 'battery-range' }, true), render({ r: 'b', k: 'sug', t: 'x', ids: GREET_PICKS }, true));
els.log.append(box); void box.offsetHeight; box.remove();
chipHTML(D.faq[0].id, 'a'); matcher.match('warm up the matcher');
});

9 · The Bold skin on the same engine
The last stage is the download exactly. The Lifestyle page moves into lifestyle/, a Bold page
joins it in bold/, and the root becomes a chooser. Both pages load the same ask.js and
core.css; the page tells the engine its skin (data-skin), and only a few lines differ: Bold adds
a "Popular" topic, shows the user's icon, labels the thumbs and puts follow-ups inside the card.
const BOLD = SKIN === 'bold';
const POPULAR = ['battery-range', 'battery-charge', 'riding-speed', 'riding-weight', 'service-interval', 'delivery-time', 'returns-policy'];
const TABS = BOLD ? [{ id: 'popular', label: 'Popular' }, ...D.topics] : D.topics;
Bold is neo-brutalist: 3 px black outlines, hard 6 px shadows, lemon #fdee3b and cyan #22e4f2
blocks, and Archivo from condensed to extended. Its topics are a "POPULAR ▾" sticker menu of
menuitemradio items.
/* primitives */
--c-paper: #f2eee5; --c-ink: #111111; --c-white: #fffdf7; --c-yellow: #fdee3b; --c-cyan: #22e4f2; --c-red: #d7261e;
/* semantic (light) */
--paper: var(--c-paper); --ink: var(--c-ink); --surface: var(--c-white); --muted: #4a4a48; --line: var(--c-ink);
--card: var(--c-yellow); --card-ink: var(--c-ink); --accent: var(--c-cyan); --accent-ink: var(--c-ink);
--shadow: var(--c-ink); --btn: var(--c-ink); --btn-ink: var(--c-cyan);
--mark: var(--c-yellow); --ring: #1d4ed8; --ok: #0f8a4a; --err: var(--c-red);
--bw: 3px; --off: 6px;
function renderTopicMenus() {
for (const m of $$('[data-ask=topic-menu]')) {
m.innerHTML = TABS.map((t) => `<button type="button" role="menuitemradio" data-topic="${t.id}" aria-checked="${t.id === state.topic}">${ICON.check}<span>${esc(t.label)}</span></button>`).join('');
}
for (const l of $$('[data-topic-label]')) l.textContent = TABS.find((t) => t.id === state.topic).label;
}
The bell sticker, "RING A HUMAN", is the hand-off. It rings with a short ding that was synthesized offline (no samples)
and a wobble; an <audio> element decodes off the main thread.
<button class="bell" type="button" data-action="handoff" data-bell data-js-only>
<span class="vh">Ring a human: talk to our rider team</span>
<svg viewBox="0 0 220 220" aria-hidden="true" focusable="false">
// bell: a short ding (synthesized offline by scripts/bell.py, no samples) and a wobble. An <audio> element decodes off the
// main thread; a WebAudio context cost ~180 ms of main thread on the first click.
let bellAudio = null;
const bellSound = () => (bellAudio ||= Object.assign(new Audio(new URL('../assets/bell.wav', import.meta.url).href), { preload: 'auto', volume: 0.6 }));
for (const b of $$('[data-bell]')) { b.addEventListener('pointerenter', bellSound, { once: true }); b.addEventListener('focus', bellSound, { once: true }); }
function ringBell(btn) {
btn.classList.remove('is-ringing'); requestAnimationFrame(() => btn.classList.add('is-ringing'));
const a = bellSound(); try { a.currentTime = 0; } catch { /* not loaded yet */ }
a.play().catch(() => { /* autoplay blocked or no audio: the wobble is enough */ });
}
Measured with the project's frame probe on a 120 Hz screen through a run of questions, streamed answers, the slider, the search and the form: no long tasks at all, at 1440 × 900 or on a 390 × 844 phone, even with the CPU slowed 4 ×, and no missed frames in the recorded runs for both pages (two of five Bold desktop runs dropped a single frame, within what a blank page dropped on the same machine).


Make it yours
- Your questions are
data/faq.json: each has a topic, a chip label, the question, keywords, the answer as blocks (paragraph, note, list, table, bike picture, range slider, button, in-answer link) and two or three follow-ups. Runscripts/build.pyand both pages are rewritten; it also checks that every follow-up, button and link points at a real question. - Free text learns your words through
SYNandSTOPinmatch.js. - A real hand-off: the form's submit is where a request to your help desk would go (today it waits 900 ms).
- Colours are tokens at the top of each skin's CSS, primitive then semantic, with a dark set.
- The photo is one line in
build.py(LIFESTYLE_PHOTO), and the rider's box for the layout is inscripts/photo.py. - Scripts can drive it through
window.__ask:ask(id),say(text),state(),reset(),match(text),idle(), andspeedto make the typing faster or slower. - Accessibility is already there: the log and a status line are live regions, the tabs, the comboboxes and the
menus follow the WAI-ARIA patterns, every field has its label and error, focus is managed for the form and the views,
and
prefers-reduced-motionis honoured.
The code is on GitHub at UlviShabanbayli/domvybe-faq-chat, under the MIT licence: use it in your own projects, commercial ones included. VOLTA is made up. The rider photo was generated with Google Gemini; the bike drawing, the icons, the bell sticker and its sound were made for this project.
Write every answer once, as plain HTML. The accordion is what a visitor without JavaScript reads and what the list view shows, and the chat clones the same markup into its bubbles. One renderer, nothing to keep in sync, and the page works before a line of JavaScript runs.