Stackless-Website/chat-widget.js

458 lines
24 KiB
JavaScript

/* ============================================================
Stackless — floating chat widget
Connects to the n8n chat workflow at:
https://n8nmac.apphub.co.za/webhook/34f4684f-a603-4207-a8a9-75746a345463
Self-contained: injects DOM + scoped styles on first load.
Survives the workflow returning 5xx — surfaces a friendly
fallback with a "message a human" path instead of crashing.
============================================================ */
(function () {
if (window.__stacklessChatLoaded) return;
window.__stacklessChatLoaded = true;
var WEBHOOK = 'https://n8nmac.apphub.co.za/webhook/34f4684f-a603-4207-a8a9-75746a345463';
var BRAND = { teal: '#1DB897', coral: '#F76857', ink: '#212A40', inkDeep: '#1C2439', paper: '#F4F2EC', surface: '#FFFFFF', border: '#E5E2D8', muted: '#6E7387' };
var STORE_KEY = 'stackless:chat:session';
var POS_KEY = 'stackless:chat:open';
var HISTORY_KEY = 'stackless:chat:history';
// ---- session id (persists per browser) ----
function getSession() {
try {
var s = localStorage.getItem(STORE_KEY);
if (!s) {
s = 'sess-' + (crypto && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
localStorage.setItem(STORE_KEY, s);
}
return s;
} catch (_) { return 'sess-anon'; }
}
// ---- styles (scoped under .cw-*) ----
var css = ''
+ '.cw-root{position:fixed;right:20px;bottom:20px;z-index:1000;font-family:Inter,-apple-system,BlinkMacSystemFont,system-ui,sans-serif;color:' + BRAND.ink + ';}'
+ '.cw-launcher{width:58px;height:58px;border-radius:50%;background:' + BRAND.ink + ';border:0;box-shadow:0 12px 30px -10px rgba(28,36,57,.45),0 0 0 1px rgba(255,255,255,.04) inset;cursor:pointer;display:grid;place-items:center;transition:transform .2s ease,background .2s ease,box-shadow .2s ease;color:#fff;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}'
+ '.cw-launcher:hover{transform:translateY(-2px);box-shadow:0 18px 38px -10px rgba(28,36,57,.55);}'
+ '.cw-launcher:focus-visible{outline:2px solid ' + BRAND.teal + ';outline-offset:3px;}'
+ '.cw-launcher svg{width:24px;height:24px;}'
+ '.cw-launcher .cw-pulse{position:absolute;width:10px;height:10px;border-radius:50%;background:' + BRAND.coral + ';top:6px;right:6px;box-shadow:0 0 0 0 rgba(247,104,87,.6);animation:cw-pulse 2s infinite;}'
+ '@keyframes cw-pulse{0%{box-shadow:0 0 0 0 rgba(247,104,87,.55);}70%{box-shadow:0 0 0 12px rgba(247,104,87,0);}100%{box-shadow:0 0 0 0 rgba(247,104,87,0);}}'
+ '.cw-panel{position:fixed;right:20px;bottom:92px;width:min(380px,calc(100vw - 32px));height:min(560px,calc(100vh - 120px));max-height:calc(100vh - 120px);background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';border-radius:20px;box-shadow:0 30px 80px -30px rgba(28,36,57,.35);display:flex;flex-direction:column;overflow:hidden;opacity:0;transform:translateY(12px) scale(.98);transform-origin:bottom right;pointer-events:none;transition:opacity .2s ease,transform .2s ease;}'
+ '.cw-panel.is-open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}'
+ '.cw-head{display:flex;align-items:center;gap:12px;padding:14px 16px;background:' + BRAND.ink + ';color:#fff;}'
+ '.cw-avatar{width:34px;height:34px;border-radius:10px;background:' + BRAND.surface + ';display:grid;place-items:center;flex:none;}'
+ '.cw-avatar svg{width:22px;height:22px;}'
+ '.cw-meta{flex:1;min-width:0;}'
+ '.cw-title{font:600 14.5px/1.2 "Inter Tight",-apple-system,system-ui,sans-serif;letter-spacing:-.01em;}'
+ '.cw-status{display:flex;align-items:center;gap:6px;margin-top:3px;font:500 11px/1 "JetBrains Mono",ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.7);}'
+ '.cw-dot{width:7px;height:7px;border-radius:50%;background:' + BRAND.teal + ';box-shadow:0 0 0 3px rgba(29,184,151,.25);}'
+ '.cw-head-btn{background:transparent;border:0;color:rgba(255,255,255,.7);cursor:pointer;width:32px;height:32px;border-radius:8px;display:grid;place-items:center;transition:background .15s,color .15s;}'
+ '.cw-head-btn:hover{background:rgba(255,255,255,.1);color:#fff;}'
+ '.cw-head-btn svg{width:16px;height:16px;}'
+ '.cw-messages{flex:1;overflow-y:auto;padding:18px 16px;background:' + BRAND.paper + ';display:flex;flex-direction:column;gap:10px;scrollbar-width:thin;scrollbar-color:' + BRAND.border + ' transparent;}'
+ '.cw-messages::-webkit-scrollbar{width:6px;}'
+ '.cw-messages::-webkit-scrollbar-thumb{background:' + BRAND.border + ';border-radius:3px;}'
+ '.cw-msg{max-width:84%;padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.5;word-wrap:break-word;white-space:pre-wrap;animation:cw-in .2s ease;}'
+ '@keyframes cw-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:translateY(0);}}'
+ '.cw-msg--bot{background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';color:' + BRAND.ink + ';border-bottom-left-radius:4px;align-self:flex-start;}'
+ '.cw-msg--user{background:' + BRAND.ink + ';color:#fff;border-bottom-right-radius:4px;align-self:flex-end;}'
+ '.cw-msg--err{background:#fff5f3;border:1px solid #f7c8bf;color:#a8341f;border-bottom-left-radius:4px;align-self:flex-start;}'
+ '.cw-msg a{color:' + BRAND.teal + ';text-decoration:underline;text-underline-offset:2px;}'
+ '.cw-msg--user a{color:#a4f0d6;}'
+ '.cw-typing{align-self:flex-start;background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';border-radius:16px;border-bottom-left-radius:4px;padding:12px 16px;display:flex;gap:4px;}'
+ '.cw-typing span{width:7px;height:7px;border-radius:50%;background:' + BRAND.muted + ';animation:cw-typing 1.2s infinite;}'
+ '.cw-typing span:nth-child(2){animation-delay:.15s;}'
+ '.cw-typing span:nth-child(3){animation-delay:.3s;}'
+ '@keyframes cw-typing{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-3px);}}'
+ '.cw-quick{padding:0 16px 12px;background:' + BRAND.paper + ';display:flex;flex-wrap:wrap;gap:6px;}'
+ '.cw-quick.is-dismissed{display:none;}'
+ '.cw-quick button{background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';border-radius:999px;padding:7px 12px;font:500 12.5px/1 Inter,system-ui,sans-serif;color:' + BRAND.ink + ';cursor:pointer;transition:background .15s,border-color .15s,color .15s;}'
+ '.cw-quick button:hover{background:' + BRAND.ink + ';color:#fff;border-color:' + BRAND.ink + ';}'
+ '.cw-form{display:flex;gap:8px;align-items:flex-end;padding:12px 14px;border-top:1px solid ' + BRAND.border + ';background:' + BRAND.surface + ';}'
+ '.cw-form textarea{flex:1;resize:none;border:1px solid ' + BRAND.border + ';border-radius:14px;padding:10px 12px;font:400 16px/1.5 Inter,system-ui,sans-serif;color:' + BRAND.ink + ';background:' + BRAND.surface + ';outline:none;min-height:42px;max-height:120px;transition:border-color .15s,box-shadow .15s;touch-action:manipulation;}'
+ '.cw-form textarea:focus{border-color:' + BRAND.teal + ';box-shadow:0 0 0 3px rgba(29,184,151,.15);}'
+ '.cw-form textarea::placeholder{color:' + BRAND.muted + ';}'
+ '.cw-send{width:44px;height:44px;border-radius:12px;background:' + BRAND.coral + ';color:#fff;border:0;cursor:pointer;display:grid;place-items:center;flex:none;transition:background .15s,transform .12s;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}'
+ '.cw-send:hover{background:#e25645;}'
+ '.cw-send:active{transform:translateY(1px);}'
+ '.cw-send:disabled{opacity:.5;cursor:not-allowed;}'
+ '.cw-send svg{width:18px;height:18px;}'
+ '.cw-foot{padding:8px 16px 12px;background:' + BRAND.surface + ';border-top:1px solid ' + BRAND.border + ';font:500 11px/1.4 "JetBrains Mono",ui-monospace,Menlo,monospace;letter-spacing:.04em;color:' + BRAND.muted + ';text-align:center;}'
+ '.cw-foot a{color:' + BRAND.ink + ';text-decoration:underline;text-underline-offset:2px;}'
+ '@media (max-width:640px){'
+ '.cw-root{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));}'
+ '.cw-launcher{width:56px;height:56px;}'
+ '.cw-panel{right:0;left:0;top:0;bottom:0;width:100%;height:100vh;height:100dvh;max-height:100dvh;border-radius:0;transform:translateY(100%);transform-origin:bottom center;}'
+ '.cw-panel.is-open{transform:translateY(0);}'
+ '.cw-panel::before{content:"";position:absolute;top:max(8px,env(safe-area-inset-top,0px));left:50%;transform:translateX(-50%);width:36px;height:4px;background:rgba(255,255,255,.32);border-radius:2px;z-index:2;pointer-events:none;}'
+ '.cw-head{padding-top:calc(16px + env(safe-area-inset-top,0px));}'
+ '.cw-form{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px));}'
+ '.cw-foot{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px));}'
+ '.cw-title{font-size:15.5px;}'
+ '.cw-msg{font-size:15px;}'
+ '.cw-quick button{padding:9px 14px;font-size:13px;}'
+ '}'
+ '@media (prefers-reduced-motion:reduce){'
+ '.cw-launcher,.cw-panel,.cw-msg{transition:none;animation:none;}'
+ '.cw-pulse{animation:none;}'
+ '}';
var style = document.createElement('style');
style.setAttribute('data-cw', 'stackless');
style.textContent = css;
document.head.appendChild(style);
// ---- icons (inline, currentColor) ----
var ICON_CHAT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
var ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
var ICON_SEND = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
var ICON_RESET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
// brand mark: three slanted capsules + coral dot (matches the logo)
var ICON_BRAND = '<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">'
+ '<rect x="6.5" y="14.5" width="13" height="4" rx="2" transform="rotate(-22 13 16.5)" fill="#AEDED3"/>'
+ '<rect x="9" y="13" width="13" height="4" rx="2" transform="rotate(-22 15.5 15)" fill="#5FBEC2"/>'
+ '<rect x="11.5" y="11.5" width="13" height="4" rx="2" transform="rotate(-22 18 13.5)" fill="#172E58"/>'
+ '<circle cx="22.5" cy="20" r="2.2" fill="#F17B61"/>'
+ '</svg>';
// ---- DOM scaffold ----
var root = document.createElement('div');
root.className = 'cw-root';
root.innerHTML = ''
+ '<button type="button" class="cw-launcher" aria-label="Open chat with stackless" aria-expanded="false">'
+ ICON_CHAT
+ '<span class="cw-pulse" aria-hidden="true"></span>'
+ '</button>'
+ '<section class="cw-panel" role="dialog" aria-label="Stackless chat" aria-hidden="true">'
+ '<header class="cw-head">'
+ '<div class="cw-avatar">' + ICON_BRAND + '</div>'
+ '<div class="cw-meta">'
+ '<div class="cw-title">stackless assistant</div>'
+ '<div class="cw-status"><span class="cw-dot"></span> online · replies in seconds</div>'
+ '</div>'
+ '<button type="button" class="cw-head-btn cw-reset" aria-label="Reset conversation" title="Reset">' + ICON_RESET + '</button>'
+ '<button type="button" class="cw-head-btn cw-close" aria-label="Close chat">' + ICON_CLOSE + '</button>'
+ '</header>'
+ '<div class="cw-messages" role="log" aria-live="polite" aria-relevant="additions"></div>'
+ '<div class="cw-quick" aria-label="Suggested questions"></div>'
+ '<form class="cw-form" novalidate>'
+ '<textarea class="cw-input" rows="1" inputmode="text" enterkeyhint="send" autocapitalize="sentences" autocorrect="off" autocomplete="off" spellcheck="true" placeholder="Ask about websites, AI agents, pricing…" aria-label="Your message"></textarea>'
+ '<button type="submit" class="cw-send" aria-label="Send">' + ICON_SEND + '</button>'
+ '</form>'
+ '<div class="cw-foot">powered by <a href="https://stackless.co.za">stackless</a> · <a href="contact.html">talk to a human</a></div>'
+ '</section>';
document.body.appendChild(root);
var launcher = root.querySelector('.cw-launcher');
var panel = root.querySelector('.cw-panel');
var closeBtn = root.querySelector('.cw-close');
var resetBtn = root.querySelector('.cw-reset');
var messages = root.querySelector('.cw-messages');
var quick = root.querySelector('.cw-quick');
var form = root.querySelector('.cw-form');
var input = root.querySelector('.cw-input');
var sendBtn = root.querySelector('.cw-send');
var QUICK_PROMPTS = [
'What can you build?',
'How much does a website cost?',
'What is Rent-an-AI-Agent?',
'How long does a project take?'
];
// ---- state ----
var history = [];
try { history = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]') || []; } catch (_) { history = []; }
var isOpen = false;
var inFlight = false;
function saveHistory() {
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-50))); } catch (_) {}
}
function renderQuick() {
quick.innerHTML = '';
QUICK_PROMPTS.forEach(function (q) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = q;
b.addEventListener('click', function () {
if (inFlight) return;
// Dismiss the recommendation row the moment the user picks one
// — otherwise it sits over the chat for the rest of the session.
quick.classList.add('is-dismissed');
input.value = q;
autoSize();
form.requestSubmit();
});
quick.appendChild(b);
});
}
// Hide the "what I can build" recommendation row once the user has
// actually started a conversation. It returns on reset.
function dismissQuick() {
quick.classList.add('is-dismissed');
}
function restoreQuick() {
quick.classList.remove('is-dismissed');
}
function escape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// Allow http(s) links inside bot messages; everything else stays escaped.
function linkify(escaped) {
return escaped.replace(/(https?:\/\/[^\s<]+)/g, function (url) {
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>';
});
}
function addMessage(role, text, opts) {
opts = opts || {};
var div = document.createElement('div');
div.className = 'cw-msg cw-msg--' + role;
div.innerHTML = linkify(escape(text));
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
if (role === 'user' || role === 'bot') {
history.push({ role: role, text: text, t: Date.now() });
saveHistory();
}
return div;
}
function addError(text) {
var div = document.createElement('div');
div.className = 'cw-msg cw-msg--err';
div.innerHTML = linkify(escape(text));
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
return div;
}
function showTyping() {
var el = document.createElement('div');
el.className = 'cw-typing';
el.setAttribute('aria-label', 'Assistant is typing');
el.innerHTML = '<span></span><span></span><span></span>';
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
return el;
}
function renderHistory() {
messages.innerHTML = '';
if (!history.length) {
addMessage('bot', "hey — i'm the stackless assistant. ask me what we build, what it costs, or how it works.");
return;
}
history.forEach(function (m) { addMessage(m.role, m.text); });
}
function open() {
if (isOpen) return;
isOpen = true;
panel.classList.add('is-open');
panel.setAttribute('aria-hidden', 'false');
launcher.setAttribute('aria-expanded', 'true');
launcher.style.transform = 'scale(.9)';
try { localStorage.setItem(POS_KEY, '1'); } catch (_) {}
// Lock background scroll so the page behind the panel doesn't move
try {
var scrollY = window.scrollY || window.pageYOffset || 0;
panel.dataset.lockedScroll = String(scrollY);
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = '-' + scrollY + 'px';
document.body.style.left = '0';
document.body.style.right = '0';
} catch (_) {}
// Only auto-focus on desktop — on mobile that would summon the keyboard
// before the user has even seen the panel.
var isMobile = window.matchMedia('(max-width: 640px)').matches;
if (!isMobile) {
setTimeout(function () { input.focus(); }, 200);
}
}
function close() {
if (!isOpen) return;
isOpen = false;
panel.classList.remove('is-open');
panel.setAttribute('aria-hidden', 'true');
launcher.setAttribute('aria-expanded', 'false');
launcher.style.transform = '';
try { localStorage.setItem(POS_KEY, '0'); } catch (_) {}
// Release background scroll and restore the scroll position
try {
var y = parseInt(panel.dataset.lockedScroll || '0', 10) || 0;
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
window.scrollTo(0, y);
delete panel.dataset.lockedScroll;
} catch (_) {}
// Clear any keyboard-driven panel resizing
panel.style.height = '';
panel.style.top = '';
}
function toggle() { isOpen ? close() : open(); }
function reset() {
history = [];
try { localStorage.removeItem(HISTORY_KEY); } catch (_) {}
try {
var s = localStorage.getItem(STORE_KEY);
localStorage.removeItem(STORE_KEY);
if (s) localStorage.setItem(STORE_KEY, 'sess-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
} catch (_) {}
restoreQuick();
renderHistory();
}
// ---- webhook call ----
function extractReply(data) {
if (data == null) return '';
if (typeof data === 'string') return data;
if (Array.isArray(data)) {
for (var i = 0; i < data.length; i++) {
var v = extractReply(data[i]);
if (v) return v;
}
return '';
}
var keys = ['output', 'text', 'response', 'message', 'reply', 'answer', 'content', 'result'];
for (var k = 0; k < keys.length; k++) {
if (typeof data[keys[k]] === 'string' && data[keys[k]].trim()) return data[keys[k]];
}
// Sometimes n8n wraps it: { data: { output: "..." } } or { json: { output: "..." } }
if (data.data) { var d = extractReply(data.data); if (d) return d; }
if (data.json) { var j = extractReply(data.json); if (j) return j; }
if (typeof data.message === 'object') { var m = extractReply(data.message); if (m) return m; }
return '';
}
function callWebhook(text) {
var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 25000);
return fetch(WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ chatInput: text, sessionId: getSession(), message: text }),
signal: ctrl ? ctrl.signal : undefined
})
.then(function (res) {
clearTimeout(timer);
var ct = (res.headers.get('content-type') || '').toLowerCase();
var parse = ct.indexOf('application/json') >= 0 ? res.json() : res.text();
return parse.then(function (body) { return { ok: res.ok, status: res.status, body: body }; });
});
}
function send(text) {
text = (text || '').trim();
if (!text || inFlight) return;
inFlight = true;
sendBtn.disabled = true;
// First real send — the suggestion row has done its job, remove it so
// it doesn't sit over the conversation for the rest of the session.
dismissQuick();
addMessage('user', text);
var typing = showTyping();
callWebhook(text)
.then(function (r) {
typing.remove();
if (!r.ok) {
var detail = (r.body && (r.body.message || r.body.error)) ? ' (' + (r.body.message || r.body.error) + ')' : '';
addError("our assistant is having a moment — try again, or tap 'talk to a human' below" + detail + '.');
return;
}
var reply = extractReply(r.body);
if (!reply) {
addError("didn't catch a reply from the assistant. try again, or message us directly on contact.html.");
return;
}
addMessage('bot', reply);
})
.catch(function (err) {
typing.remove();
var msg = (err && err.name === 'AbortError')
? "that took longer than expected. try again, or message us on contact.html."
: "couldn't reach the assistant. check your connection, or message us on contact.html.";
addError(msg);
})
.then(function () {
inFlight = false;
sendBtn.disabled = false;
input.focus();
});
}
// ---- input behaviour ----
function autoSize() {
input.style.height = 'auto';
input.style.height = Math.min(120, input.scrollHeight) + 'px';
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var v = input.value;
input.value = '';
autoSize();
send(v);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
input.addEventListener('input', autoSize);
launcher.addEventListener('click', toggle);
closeBtn.addEventListener('click', close);
resetBtn.addEventListener('click', function () { reset(); input.focus(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen) close();
});
// ---- boot ----
renderHistory();
renderQuick();
// Only auto-reopen the panel on desktop — on mobile this would slam a
// fullscreen overlay over the page on every reload.
try {
if (localStorage.getItem(POS_KEY) === '1' && !window.matchMedia('(max-width: 640px)').matches) open();
} catch (_) {}
// ---- visual viewport (mobile keyboard handling) ----
// When the soft keyboard opens on mobile, shrink the panel to the visible
// viewport so the input stays above the keyboard. The panel is full-screen
// on mobile, so we just resize it to the visible height.
if (window.visualViewport) {
var vv = window.visualViewport;
function handleVV() {
if (!isOpen) return;
if (!window.matchMedia('(max-width: 640px)').matches) return;
// vv.height shrinks when the keyboard appears; vv.offsetTop rises.
panel.style.height = vv.height + 'px';
panel.style.top = vv.offsetTop + 'px';
// Keep the latest message visible above the keyboard.
var wasAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 80;
if (wasAtBottom) {
requestAnimationFrame(function () { messages.scrollTop = messages.scrollHeight; });
}
}
vv.addEventListener('resize', handleVV);
vv.addEventListener('scroll', handleVV);
}
})();