Library / Tutorial

FAQ chat,
step by step.

An FAQ section that answers like a chat. Tap a question or type your own, watch VOLTA type, and the answer streams in, with a live battery slider where it helps. Search with highlights, thumbs, a hand-off form, a list view and dark mode, in two skins on one engine. Nine stages, each a live page you can run.

9Stages
24Answers
0Libraries

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).

index.html — the intro
<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>
index.html — one question, as build.py writes it
<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.

core.css — the no-JS rules
/* ---------- 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.

lifestyle.css — the photo
.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.

lifestyle.css — right of the rider
/* 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.

lifestyle.css — the stage
.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.

lifestyle.css — the glass panel
.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); }
Stage 1 · the FAQ as a pageOpen ↗
The VOLTA questions page with no JavaScript: a rider on a coastal road, the FAQ heading, and a frosted-glass panel listing the questions, the range question open with the bike, Eco 120 km, Tour 90 km, Turbo 60 km.

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.

index.html — the inline script in the head, before the first paint
(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.

index.html — the chat panel
<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>
lifestyle.css — the same glass
.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.

ask.js — the dataset
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]));
ask.js — the showcase conversation
// 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.

ask.js — an answer, cloned from the accordion
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;
}
ask.js — a message
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".

ask.js — the battery slider
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));
  }
}
core.css — the track filled up to the thumb
/* 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.

ask.js — the chips
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 });
  }
}
ask.js — scroll to the question, not the bottom
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 });
}
ask.js — post a message; a chip asks
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);
}
Stage 2 · answers as bubblesOpen ↗
The chat panel over the photo: the question How far does one charge take me? and its card with the bike, the battery slider at Long Range 750 Wh, Eco 144 km, Tour 108 km, Turbo 72 km, and a Compare batteries button.

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.

ask.js — how long VOLTA types
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.

ask.js — take the reply apart
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;
}
ask.js — the reply
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.

ask.js — three words a span
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`);
}
core.css — words fade in, blocks rise in
/* 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); } }
core.css — the typing dots
/* 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.

ask.js — settle a message
// 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.

core.css — reduced motion
@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; }
}
Stage 3 · streamed wordsOpen ↗
A second answer arriving in the chat: How many years will the battery last?, its first words visible and the rest still fading in.

4 · Free text: the composer and the matcher

A composer under the chips sends your own words.

index.html — the composer
<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>
ask.js — send
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.

ask.js — what to do with your words
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 });
}
match.js — the intents
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.

match.js — a word, stemmed
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;
}
match.js — the words of a text
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".

match.js — the index
// 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.

match.js — typos
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.

match.js — the match
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) };
  }
Stage 4 · free textOpen ↗
The chat after typing battery: VOLTA says it is not sure what battery means and offers three questions as chips: Which battery should I choose?, How many years will the battery last?, Is the battery covered by the warranty?

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.

ask.js — the tabs
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)); }
  }
}
ask.js — pick a topic
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();
}
ask.js — the tabs' keys
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.

index.html — the search
<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.

ask.js — find
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 };
}
ask.js — mark the matches
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.

ask.js — the list
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.

ask.js — the combobox's keys
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); }
  }
});
Stage 5 · searchOpen ↗
The search box with charge typed: a list of two questions with charge highlighted in yellow, How far does one charge take me? and How long does charging take?, then Ask charge in the chat.

6 · Feedback and a person

Every answer ends with two thumbs. Up says thanks; down says sorry and offers a person.

ask.js — the thumbs
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>`;
}
ask.js — give feedback
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.

ask.js — open the hand-off
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));
}
ask.js — the form, in a bubble
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.

ask.js — the checks
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;
  };
lifestyle.css — a wrong field
.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.

ask.js — send
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);
  });
Stage 6 · the hand-off formOpen ↗
The hand-off form in the chat after an empty send: Talk to a person, Please fix 3 fields below, and the name, email and message fields outlined in red with their errors.

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.

ask.js — storage that never throws
// ---------- 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 */ } },
};
ask.js — the history and the thumbs, read back
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.

ask.js — save
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.

index.html — the head script now also reads the theme and the view
(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){}})();
ask.js — the theme
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');
}
lifestyle.css — the dark tokens
[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.

ask.js — the view
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'); }
  }
}
ask.js — narrow the list
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;
}
ask.js — jump to a topic
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 });
}
ask.js — New chat
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');
}
Stage 7 · dark mode and the list viewOpen ↗
The page in dark mode with the list view: the Battery tab selected, the battery questions listed and How long does charging take? open with a table of charging times.

8 · The phone

Under 700 px wide (or 500 px tall) the chat becomes the whole screen, still frosted, over the photo.

lifestyle.css — the chat, full screen
.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.

index.html — the ⋯ menu
<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>
ask.js — open and close a menu
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);
}
ask.js — the menu's keys
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();
});
ask.js — the search row
'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.

ask.js — minimise and reopen
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(); }
}
lifestyle.css — the launcher
.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.

ask.js — the warm-up
// 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');
});
Stage 8 · the phoneOpen ↗
The Lifestyle page on a phone: the chat full screen over the photo, the range card, and the ⋯ menu open with New chat, Talk to a person, List view and Dark mode.

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.

ask.js — one engine, two skins
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.

bold.css — the tokens
/* 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;
ask.js — the topic menu
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.

bold/index.html — the bell sticker
<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">
ask.js — ring the bell
// 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).

Stage 9 · the Bold pageOpen ↗
The Bold page: a yellow answer card with thick black outlines, the bike, the battery slider at Long Range 750 Wh, a MODE, RANGE, SUPPORT table, SEE BATTERY SPECS, and a round RING A HUMAN bell sticker beside it.
Stage 9 · Bold on a phoneOpen ↗
The Bold page on a phone: the yellow range card with the bike and the slider at Long Range, the MODE, RANGE, SUPPORT table, SEE BATTERY SPECS, and the chip row above the composer.

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. Run scripts/build.py and 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 SYN and STOP in match.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 in scripts/photo.py.
  • Scripts can drive it through window.__ask: ask(id), say(text), state(), reset(), match(text), idle(), and speed to 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-motion is 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.

The one idea worth keeping

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.