1/* ============================================================================================================ 2 CONCEPT I: THE CHAT UI. The landing page's big box, the corner chat on every other page, page changes with a 3 highlight on arrival, and the booking card. The brain is ADRO_AGENT.ask() in agent.js: the model on /api/chat first, 4 the local rules (respond()) whenever the model isn't available. 5 The conversation is kept in sessionStorage ('adrolab-chat-v1'), so it follows the visitor from page to page. 6 URL helpers (screenshots): ?demo=<scenario> seeds a conversation and applies its action (see DEMOS); 7 ?ask=<text> sends a message for real (the whole path, page change included); ?ui=open|min|preview sets the corner chat; ?type=<text> puts text in the landing box; ?hl=<anchor> marks an anchor. 8 ============================================================================================================ */ 9(()=>{ 10const AG=window.ADRO_AGENT;if(!AG)return; 11const Q=new URLSearchParams(location.search); 12const RM=matchMedia('(prefers-reduced-motion: reduce)').matches||Q.has('static'); 13const KEY='adrolab-chat-v1'; 14// the page's key ('about.html'): from /j/about.html on the test site, or /about (clean URLs at the root) on the real site. A page 15// outside the seven (privacy, terms, the 404) gets its own name, so every page is still "somewhere else" for navigation. 16// An article sits one folder down (lab-notes/<slug>.html, or /lab-notes/<slug>): its key keeps the folder. 17const SEG=location.pathname.split('/'),SUB=SEG.length>2&&decodeURIComponent(SEG[SEG.length-2])==='lab-notes'&&!!SEG[SEG.length-1]; 18const PAGE=(()=>{const f=decodeURIComponent(SEG[SEG.length-1]||'').replace(/\.html$/,'');return !f||f==='index'?'index.html':(SUB?'lab-notes/':'')+f+'.html'})(); 19// where a page key lives: the launch build marks <html data-clean> and serves /about; the test site keeps about.html 20const CLEAN=document.documentElement.hasAttribute('data-clean'); 21const HREF=p=>CLEAN?(p==='index.html'?'/':'/'+p.replace(/\.html$/,'')):(SUB?'../':'')+p; 22const LAND=document.getElementById('land'); 23const live=document.getElementById('chat-live'); 24const wait=ms=>new Promise(r=>setTimeout(r,ms)); 25const MOB=()=>innerWidth<=760; 26// concept J: the chat screen with H's journey underneath. The full chat lives on the chat screen; once they scroll down, the 27// conversation carries on in the corner preview (the slim bar on phones), and it steps out of the way of the breakdown 28// cards and the contact screen. 29const TRACK=document.querySelector('.track'); 30const JM=!!(LAND&&TRACK); 31 32/* ---- THE ONE PLACE A REAL SUBMIT GOES: /api/book (share3/api/book.js) emails the team and sends the visitor a 33 confirmation. The payload is the booking plus the conversation, so each chat becomes a lead. "website" is a honeypot: 34 a hidden field people never fill, so a filled one means a bot. ---- */ 35const LEAD_ENDPOINT='/api/book'; 36async function submitLead(lead){ 37 const hp=document.querySelector('input[name="website"]'); 38 try{const r=await fetch(LEAD_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({},lead,{website:hp?hp.value:''}))}); 39 let j=null;try{j=await r.json()}catch(e){} 40 return j&&typeof j==='object'?j:{ok:false,reason:'http_'+r.status}} 41 catch(e){return{ok:false,reason:'network'}} 42} 43function mailtoFor(c){const d=c.data||{}; 44 const body=`Hello ADROLAB,\n\nI'd like to request: ${c.title||KIND_T[c.kind]||'a call'}\n\nName: ${d.name||''}\nEmail: ${d.email||''}\nPhone: ${d.phone||''}${d.note?'\nNote: '+d.note:''}\n`; 45 return 'mailto:[email protected]?subject='+encodeURIComponent((c.title||'Request')+(d.name?': '+d.name:''))+'&body='+encodeURIComponent(body)} 46 47// ---- state ---- 48const fresh=()=>({msgs:[],st:{},ui:null,pending:null}); 49let S=(()=>{try{const v=JSON.parse(sessionStorage.getItem(KEY)||'null');if(v&&Array.isArray(v.msgs))return v}catch(e){}return fresh()})(); 50const save=()=>{try{sessionStorage.setItem(KEY,JSON.stringify(S))}catch(e){}}; 51 52// ---- small pieces ---- 53const esc=t=>String(t).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 54// links: [text](url) from the model, and (QA 29 Sep 2026) a bare address of one of our own sites, which the model sometimes 55// writes out ("join the waitlist at https://realsight.app"): shown as a link too, without the trailing full stop 56const OWN=/^https:\/\/(clinics\.adrolab\.ai|adrodub\.adrolab\.ai|adrolab\.ai|realsight\.app|chromewebstore\.google\.com\/detail\/adrodub\/|www\.linkedin\.com\/company\/adrolab-inc|x\.com\/adrolab|www\.instagram\.com\/adrolab\.ai|www\.youtube\.com\/@AdroLab)/; 57const lk=(a,u)=>`<a href="${u}"${/^http/.test(u)?' class="ext" target="_blank" rel="noopener noreferrer"':''}>${a}</a>`; 58function fmt(t){return esc(t).replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)\s]+)\)|(https:\/\/[^\s<>"()]*[^\s<>"().,;:!?])/g,(m,a,u,b)=>b?(OWN.test(b)?lk(b.replace(/^https:\/\//,'').replace(/\/$/,''),b):b):lk(a,u))} 59const ARR='<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7h9.5M7.5 3l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>'; 60const EXT='<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M3.5 10.5l7-7M5 3.5h5.5V9" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>'; 61const isExt=u=>/^https?:/i.test(u||''); 62const SEND='<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 10h13M11 4.5l5.5 5.5-5.5 5.5" fill="none" stroke="currentColor" stroke-width="2.2"/></svg>'; 63const TICK='<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="1" y="1" width="18" height="18" fill="#324E48"/><path d="M5 10.5l3.2 3 6.6-7" fill="none" stroke="#EFE7DA" stroke-width="2.4"/></svg>'; 64const KIND_T={audit:'AI Audit request',meeting:'Call with the team'}; 65function card(c){const d=c.data; 66 return `<div class="bk" role="group" aria-label="${esc(c.title)}"><h3>${TICK}${esc(c.title||KIND_T[c.kind])}</h3><dl>`+ 67 `<dt>Name</dt><dd>${esc(d.name)}</dd><dt>Email</dt><dd>${esc(d.email)}</dd><dt>Phone</dt><dd>${esc(d.phone)}</dd>`+(d.note?`<dt>Note</dt><dd>${esc(d.note)}</dd>`:'')+ 68 `</dl>${cardStatus(c)}</div>`} 69function cardStatus(c){ 70 if(c.status==='sending')return `<p class="pv" role="status">Sendingâ¦</p>`; 71 if(c.status==='sent')return `<p class="pv ok" role="status">Sent. Check your inbox for a confirmation.</p>`; 72 if(c.status==='sent-team')return `<p class="pv ok" role="status">Sent to our team. We'll reply to ${esc(c.data.email)}.</p>`; 73 if(c.status==='failed')return `<p class="pv bad" role="status">We couldn't send this automatically. Please email <a href="${esc(mailtoFor(c))}">[email protected]</a>.</p>`; 74 return ''} 75function msgHTML(m,isLast){ 76 if(m.r==='u')return `<div class="msg u">${esc(m.t)}</div>`; 77 let h=`<div class="msg a"><p class="t">${fmt(m.t)}</p>`; 78 if(m.card)h+=card(m.card); 79 if(m.nav&&AG.PAGES[m.nav])h+=`<a class="nv" href="${HREF(m.nav)}" data-nav="${m.nav}">Opened ${esc(AG.PAGES[m.nav])} ${ARR}</a>`; 80 if(isLast&&m.chips&&m.chips.length){const grouped=m.chips.some(c=>c&&c.group);h+=`<div class="chips${grouped?' grouped':''}" role="group" aria-label="Suggested replies">`; 81 // grouped chips (the offerings: Agentic systems, AI products, AI Audit): one row per group, with its name above it 82 let g=null; 83 m.chips.forEach((c,i)=>{if(!c||!c.label)return; 84 if(grouped&&c.group!==g){h+=(g!==null?'</div>':'')+`<div class="chip-row" role="group" aria-label="${esc(c.group||'More')}">`+(c.group?`<span class="chip-g" aria-hidden="true">${esc(c.group)}</span>`:'');g=c.group;} 85 h+=c.href?(isExt(c.href)?`<a class="chip" href="${esc(c.href)}" target="_blank" rel="noopener noreferrer">${esc(c.label)} ${EXT}</a>`:`<a class="chip" href="${esc(c.href)}">${esc(c.label)} ${ARR}</a>`) 86 :`<button type="button" class="chip" data-i="${i}"${m.multi?' aria-pressed="false"':''}>${esc(c.label)}</button>`}); 87 if(grouped&&g!==null)h+='</div>'; 88 if(m.multi)h+=`<button type="button" class="chip go" data-act="multi" disabled>Continue ${ARR}</button>`; 89 h+='</div>';} 90 return h+'</div>'; 91} 92// the label shown for a tapped quiz token (for demos, where no chip was tapped) 93function tokenLabel(t){const m=t.match(/^#q(\d):([\d,]*)$/);if(!m||!window.ADRO_QUIZ)return t;const O=ADRO_QUIZ.QS[+m[1]-1].opts;
94 return m[2].split(',').filter(Boolean).map(i=>O[+i]&&O[+i].l).filter(Boolean).join(', ')} 95 96// ---- views: the landing thread, or the corner chat ---- 97let V=null,VL=null,DK=null; 98function buildDock(){ 99 const mark=((document.querySelector('.brand img.m')||{}).src||'').replace('logo-mark.svg','logo-mark-cream.svg'); 100 const d=document.createElement('aside');d.className='dock';d.setAttribute('aria-label','Chat with the lab'); 101 d.innerHTML=`<div class="dk-head"><img src="${mark}" alt=""><span class="dk-title"><span class="live" aria-hidden="true"></span>Ask the lab</span>`+ 102 `<button type="button" class="dk-peek" data-act="preview" aria-label="Show the chat"></button>`+ 103 `<button type="button" class="dk-b dk-new" data-act="new" aria-label="New chat" title="New chat"><svg viewBox="0 0 16 16"><path d="M13.5 8a5.5 5.5 0 1 1-1.7-4" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12.4 1.4v3.2H9.2" fill="none" stroke="currentColor" stroke-width="1.7"/></svg></button>`+ 104 `<button type="button" class="dk-b dk-min" data-act="min" aria-label="Minimise chat" title="Minimise"><svg viewBox="0 0 16 16"><path d="M3 8.5h10" stroke="currentColor" stroke-width="1.8"/></svg></button>`+ 105 `<button type="button" class="dk-b dk-exp" data-act="toggle" aria-label="Expand chat" title="Expand"><svg class="ic-exp" viewBox="0 0 16 16"><path d="M9.5 2.5h4v4M13.5 2.5l-5 5M6.5 13.5h-4v-4M2.5 13.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7"/></svg><svg class="ic-col" viewBox="0 0 16 16"><path d="M13 7h-4V3M9 7l5-5M3 9h4v4M7 9l-5 5" fill="none" stroke="currentColor" stroke-width="1.7"/></svg></button></div>`+ 106 `<div class="dk-log chat-log" role="log" aria-live="off" aria-label="Conversation"></div>`+ 107 `<form class="dk-form" autocomplete="off"><label class="sr" for="dk-in">Message the lab</label><input id="dk-in" type="text" maxlength="1500" enterkeyhint="send" placeholder="Ask a follow-upâ¦"><button class="dk-send" type="submit" aria-label="Send">${SEND}</button></form>`; 108 document.body.appendChild(d);return d; 109} 110if(LAND){ 111 const th=document.getElementById('land-thread'); 112 VL={el:LAND,log:th.querySelector('.lt-log'),input:document.getElementById('ask-in'),form:document.getElementById('ask'),thread:th}; 113} 114if(!LAND||JM){ 115 const d=buildDock();DK={el:d,dock:d,log:d.querySelector('.dk-log'),input:d.querySelector('#dk-in'),form:d.querySelector('.dk-form')}; 116 if(JM)d.classList.add('j-off'); 117} 118V=VL||DK; 119const VIEWS=[VL,DK].filter(Boolean); 120// the log stays pinned to the newest message while its box changes size (fonts arriving, the mark shrinking, the phone 121// keyboard): the landing thread used to open scrolled to the top when a longer conversation came back from another page 122VIEWS.forEach(v=>{v.stick=true;v.log.addEventListener('scroll',()=>{v.stick=v.log.scrollTop+v.log.clientHeight>=v.log.scrollHeight-40},{passive:true}); 123 if(window.ResizeObserver)new ResizeObserver(()=>{if(v.stick)v.log.scrollTop=v.log.scrollHeight}).observe(v.log);}); 124// J: is the corner chat the one in use right now (the chat screen is scrolled away)? 125let dockShown=!JM; 126const AV=()=>JM?(dockShown?DK:VL):V; 127// J: show the corner chat once the chat screen is mostly scrolled away (and only when there is a conversation); on phones, 128// keep the chat bar's room free at the bottom of the journey; and keep the corner chat off the cards and the contact screen 129let jPad=-1,jFullH=260,jSlimH=54,jRaf=0; 130function jSync(){if(!JM)return; 131 const has=S.msgs.length>0,want=has&&LAND.getBoundingClientRect().bottom<innerHeight*.5,d=DK.dock; 132 if(want!==dockShown){dockShown=want;d.classList.toggle('j-off',!want); 133 if(want&&!RM&&!Q.has('freeze')){d.classList.remove('in');void d.offsetWidth;d.classList.add('in');} 134 document.body.classList.toggle('chat-open',S.ui==='open'&&MOB()&&want);} 135 const pad=MOB()&&has?Math.round((S.ui==='open'?110:d.offsetHeight)+10):0; 136 if(pad!==jPad){const first=jPad<0;jPad=pad;window.__jPadB=pad;
136document.documentElement.style.setProperty('--jpadb',pad+'px');if(!first||pad>0)dispatchEvent(new Event('resize'));} 137 jAvoid();} 138function jObst(){const out=[],vh=innerHeight; 139 document.querySelectorAll('.pcard.live').forEach(el=>{if(+(el.style.opacity||0)>.05)out.push(el.getBoundingClientRect())}); 140 const fi=document.querySelector('.fin-in');if(fi&&+(fi.style.opacity||1)>.05)[...fi.children].forEach(c=>out.push(c.getBoundingClientRect())); 141 return out.filter(r=>r.width>0&&r.bottom>0&&r.top<vh);} 142function jAvoid(){if(!JM)return;const d=DK.dock;let tuck=false,left=false,hide=false; 143 if(dockShown&&S.ui!=='open'){const ob=jObst(),vw=innerWidth,vh=innerHeight,g=12; 144 const hit=r=>ob.some(o=>o.left<r.right+g&&o.right>r.left-g&&o.top<r.bottom+g&&o.bottom>r.top-g); 145 if(MOB())hide=hit({left:10,right:vw-10,top:vh-10-d.offsetHeight,bottom:vh-10}); 146 else{const tk=d.classList.contains('j-tuck');if(!tk&&S.ui!=='min')jFullH=d.offsetHeight||jFullH;else if(tk)jSlimH=d.offsetHeight||jSlimH; 147 const m=Math.min(26,Math.max(14,vw*.016)),F={w:Math.min(380,vw-28),h:jFullH},Sl={w:Math.min(340,vw-28),h:jSlimH}; 148 const C=(S.ui==='min'?[[Sl,0,0],[Sl,0,1]]:[[F,0,0],[Sl,1,0],[F,0,1],[Sl,1,1]]); 149 const R=(z,l)=>({left:l?m:vw-m-z.w,right:l?m+z.w:vw-m,top:vh-m-z.h,bottom:vh-m}); 150 // first choice: clear of the cards, the contact screen and the mark; then clear of the cards and the contact screen 151 const mk=window.__jMark&&window.__jMark(),hitM=r=>mk&&mk.left<r.right&&mk.right>r.left&&mk.top<r.bottom&&mk.bottom>r.top; 152 const ok=C.find(c=>{const r=R(c[0],c[2]);return !hit(r)&&!hitM(r)}
152)||C.find(c=>!hit(R(c[0],c[2])));if(ok){tuck=!!ok[1];left=!!ok[2];}else hide=true;}} 153 d.classList.toggle('j-tuck',tuck);d.classList.toggle('j-left',left);d.classList.toggle('j-hide',hide);} 154if(JM){ 155 // the quiet cue at the bottom of the chat screen: a smooth scroll down to the start of the journey 156 const cue=LAND.querySelector('.land-cue');if(cue)cue.addEventListener('click',e=>{e.preventDefault();scrollTo({top:TRACK.getBoundingClientRect().top+scrollY,behavior:RM?'instant':'smooth'});}); 157 addEventListener('scroll',()=>{if(!jRaf)jRaf=requestAnimationFrame(()=>{jRaf=0;jSync()})},{passive:true}); 158 addEventListener('resize',()=>setTimeout(jSync,200)); 159 // the breakdown's cards keep easing into place after the scroll stops 160 setInterval(()=>{if(dockShown)jAvoid()},220);} 161const GREETING={r:'a',t:'Hi, I\'m the lab\'s agent. Ask me anything, and I\'ll take you to the answer on this site.',chips:[{label:'What is ADROLAB?',send:'What does ADROLAB do?'},{label:'Which product fits me?',send:'Which product fits me?'},{label:'Book an AI Audit',send:'Book an AI Audit'}]}; 162 163function setUI(ui){S.ui=ui;save();if(DK){DK.dock.dataset.ui=ui;const t=DK.dock.querySelector('.dk-exp');t.setAttribute('aria-label',ui==='open'?'Collapse chat':'Expand chat');t.title=ui==='open'?'Collapse':'Expand'; 164 document.body.classList.toggle('chat-open',ui==='open'&&MOB()&&dockShown);render();jSync();}} 165function render(){ 166 if(VL){LAND.classList.toggle('talking',S.msgs.length>0);VL.thread.hidden=!S.msgs.length; 167 if(S.msgs.length){const ph=VL.form.querySelector('.ask-ph-t');ph.textContent=S.st.flow&&S.st.flow.type==='book'?'Type your answerâ¦':S.st.flow&&S.st.flow.type==='quiz'?'Tap an option above, or type your answerâ¦':'Ask a follow-upâ¦'}} 168 for(const v of VIEWS){ 169 const msgs=S.msgs.length?S.msgs:(v.dock?[GREETING]:[]); 170 const n=msgs.length; 171 v.log.innerHTML=msgs.map((m,i)=>msgHTML(m,i===n-1)).join(''); 172 const els=[...v.log.children]; 173 // the corner preview shows only the last one or two messages 174 if(v.dock){els.forEach((e,i)=>{e.classList.toggle('old',i<n-2);e.classList.toggle('last',i===n-1)}); 175 const lastA=[...msgs].reverse().find(m=>m.r==='a');const pk=v.dock.querySelector('.dk-peek');pk.textContent=lastA?lastA.t.replace(/\[([^\]]+)\]\([^)]*\)/g,'$1'):'';} 176 v.log.scrollTop=v.log.scrollHeight;v.stick=true;} 177} 178function addTyping(){const ts=VIEWS.map(v=>{const t=document.createElement('div');t.className='typing';t.setAttribute('aria-hidden','true');t.innerHTML='<i></i><i></i><i></i>';v.log.appendChild(t);v.log.scrollTop=v.log.scrollHeight;return t}); 179 return{remove:()=>ts.forEach(t=>t.remove())}} 180// the quick word-by-word reveal (under a second, whatever the length), in the view in use 181function revealLast(){if(RM)return 0;const L=AV().log;const m=L.querySelector('.msg.a:last-of-type .t')||[...L.querySelectorAll('.msg.a .t')].pop();if(!m)return 0; 182 const walk=document.createTreeWalker(m,NodeFilter.SHOW_TEXT),nodes=[];while(walk.nextNode())nodes.push(walk.currentNode); 183 let k=0;const words=[];nodes.forEach(tn=>{const parts=tn.textContent.split(/(\s+)/),f=document.createDocumentFragment(); 184 parts.forEach(p=>{if(!p)return;if(/^\s+$/.test(p)){f.appendChild(document.createTextNode(p));return}const s=document.createElement('span');s.className='w';s.textContent=p;words.push(s);f.appendChild(s)});tn.parentNode.replaceChild(f,tn)}); 185 const step=Math.min(32,560/Math.max(1,words.length));words.forEach((w,i)=>w.style.animationDelay=Math.round(i*step)+'ms'); 186 const extras=[...m.parentNode.children].filter(e=>e!==m);extras.forEach(e=>{e.style.opacity='0';e.style.transition='opacity .25s'}); 187 const total=Math.round(words.length*step)+120;setTimeout(()=>extras.forEach(e=>e.style.opacity=''),total);return total} 188 189// ---- highlights ---- 190const els=id=>id?[...document.querySelectorAll(`[data-anchor~="${id}"]`)]:[]; 191function clearHL(){document.querySelectorAll('.hl-tint,.hl-cell,.hl-on').forEach(e=>e.classList.remove('hl-tint','hl-cell','hl-on'))} 192// the highlighter: every run of text inside the element gets a marker stroke behind it (blocks inside are walked into) 193function markText(el){ 194 if(/^(CANVAS|SVG|BR|IMG)$/i.test(el.tagName)||el.classList.contains('sr')||el.classList.contains('n')||el.classList.contains('hl-ink'))return[]; 195 if(el.querySelector(':scope > .hl-ink'))return[el]; 196 const out=[];let run=[]; 197 const flush=()=>
197{if(run.some(n=>n.textContent.trim())){const s=document.createElement('span');s.className='hl-ink';run[0].parentNode.insertBefore(s,run[0]);run.forEach(n=>s.appendChild(n));if(!out.includes(el))out.push(el)}run=[]}; 198 [...el.childNodes].forEach(n=>{ 199 if(n.nodeType===1&&(getComputedStyle(n).display!=='inline'||n.classList.contains('sr'))){flush();out.push(...markText(n))} 200 else if(n.nodeType===1||n.nodeType===3)run.push(n)}); 201 flush();return out;} 202function scrollToEl(el,instant){if(!el)return;const r=el.getBoundingClientRect(),vh=innerHeight,mob=MOB(); 203 // keep it clear of the corner chat: above it when they overlap sideways 204 let bot=vh-(mob?140:40);const top=mob?64:84; 205 if(DK&&dockShown&&!mob){const d=DK.dock.getBoundingClientRect();if(r.right>d.left-8)bot=Math.min(bot,d.top-20)} 206 if(r.top>=top&&r.bottom<=bot&&r.height>0)return; 207 const room=bot-top,y=scrollY+r.top-(r.height<room*.8?top+Math.min((room-r.height)*.3,vh*.22):top); 208 scrollTo({top:Math.max(0,y),behavior:instant||RM?'instant':'smooth'});} 209function applyHL(h,instant){if(!h)return false;clearHL(); 210 const tints=(h.tint||[]).flatMap(els),marks=(h.mark||[]).flatMap(els);if(!tints.length&&!marks.length)return false;
211 tints.forEach(e=>e.classList.add(e.matches('[role=cell],[role=columnheader],[role=row]')?'hl-cell':'hl-tint')); 212 const inks=marks.flatMap(markText); 213 const f=els(h.focus)[0]||tints[0]||marks[0];scrollToEl(f,instant); 214 const on=()=>[...tints,...inks].forEach(e=>e.classList.add('hl-on')); 215 document.documentElement.classList.toggle('hl-now',!!instant); 216 if(instant||RM)on();else setTimeout(on,380); 217 return true;} 218 219// ---- going to a page (with the highlight applied when it opens) ---- 220const KEEP=['t','freeze','static','dbg','ui','div','divr']; 221function go(page){const p=new URLSearchParams();KEEP.forEach(k=>{if(Q.has(k))p.set(k,Q.get(k))});const qs=p.toString(); 222 document.body.classList.add('leaving');setTimeout(()=>location.href=HREF(page)+(qs?'?'+qs:''),RM?0:180);} 223 224// ---- one turn ---- 225let busy=false; 226// a message typed and sent while the previous reply is still arriving waits its turn (it used to be dropped, and the box cleared) 227let queued=null; 228const flushQueue=()=>{if(queued!=null&&!busy){const q=queued;queued=null;setTimeout(()=>send(q),0)}}; 229function agentMsg(res){const a=res.action||{};const m={r:'a',t:res.reply}; 230 if(a.chips&&a.chips.length){m.chips=a.chips;if(a.multi)m.multi=true} 231 if(a.card)m.card=a.card;if(a.navigate&&a.navigate!==PAGE)m.nav=a.navigate;return m} 232async function send(text,label,opts){ 233 text=String(text||'').trim();if(!text||busy)return;busy=true; 234 if(DK&&dockShown&&S.ui==='min')setUI('preview');
235 S.msgs.push({r:'u',t:label||(text.startsWith('#q')?tokenLabel(text):text)});save();render(); 236 const ty=addTyping();const minWait=wait(RM?60:260+Math.random()*160); 237 const history=S.msgs.slice(0,-1).slice(-12).filter(x=>x.t).map(x=>({role:x.r==='u'?'user':'assistant',text:String(x.t)})); 238 let res;try{res=await AG.ask(text,S.st,{history,page:PAGE})}catch(e){res=null} 239 if(!res||typeof res.reply!=='string'){try{res=AG.respond(text,S.st)}catch(e){res={reply:'Sorry, something went wrong on my side. You can always email [[email protected]](mailto:[email protected]).',action:{}}}} 240 await minWait; 241 S.st=res.state||S.st;const a=res.action||{};const m=agentMsg(res); 242 ty.remove();S.msgs.push(m);save(); 243 // lots of options (the quiz): open the full chat so they all fit 244 if(AV().dock&&m.chips&&m.chips.length>4&&S.ui!=='open'&&!MOB())setUI('open');else render(); 245 const dur=revealLast();if(live)live.textContent=m.t.replace(/\[([^\]]+)\]\([^)]*\)/g,'$1'); 246 await wait(dur); 247 if(a.flow==='booking-done'&&a.card){m.card.status='sending';save();render(); 248 const r=await submitLead({kind:a.card.kind,...a.card.data,page:PAGE,conversation:S.msgs.slice(-20).map(x=>({from:x.r==='u'?'visitor':'agent',text:String(x.t||'').slice(0,300)}))}); 249 m.card.status=r.ok?(r.confirmation===false?'sent-team':'sent'):'failed';save();render();} 250 busy=false; 251 if(a.navigate&&a.navigate!==PAGE){queued=null;S.pending={highlight:a.highlight||null,quiz:a.quiz||null};if(!S.ui||S.ui==='min')S.ui='preview';if(S.ui==='open'&&MOB())S.ui='preview';save(); 252 await wait(RM?120:700);go(a.navigate);return;} 253 if(a.quiz&&window.__quizApply)window.__quizApply(a.quiz); 254 if(a.highlight){if(DK&&MOB()&&S.ui==='open')setUI('preview');applyHL(a.highlight);} 255 if(!(opts&&opts.noFocus)&&!MOB())AV().input.focus({preventScroll:true}); 256 flushQueue(); 257} 258 259// the honeypot: off-screen, skipped by keyboard and screen readers; only bots fill it 260{const hp=document.createElement('input');hp.type='text';hp.name='website';hp.tabIndex=-1;hp.autocomplete='off';hp.setAttribute('aria-hidden','true'); 261 hp.style.cssText='position:absolute;left:-9999px;width:1px;height:1px;opacity:0';V.form.appendChild(hp);} 262// (one honeypot is enough: submitLead reads the first input[name="website"] on the page) 263 264// ---- events ---- 265VIEWS.forEach(W=>{ 266W.form.addEventListener('submit',e=>{e.preventDefault();const v=W.input.value;if(!v.trim())return;W.input.value='';syncBox();if(busy){queued=v;return}send(v);}); 267W.log.addEventListener('click',e=>{ 268 const b=e.target.closest('button.chip'); 269 if(b){const last=[...S.msgs].reverse().find(m=>m.r==='a')||GREETING; 270 if(b.dataset.act==='multi'){const sel=[...W.log.querySelectorAll('.chip[aria-pressed="true"]')];if(!sel.length)return; 271 const idx=sel.map(x=>+x.dataset.i);send('#q2:'+idx.map(i=>last.chips[i].send.split(':')[1]).join(','),sel.map(x=>x.textContent).join(', '));return} 272 const c=last.chips&&last.chips[+b.dataset.i];if(!c)return; 273 if(last.multi){const on=b.getAttribute('aria-pressed')!=='true';b.setAttribute('aria-pressed',String(on));const go=W.log.querySelector('.chip.go');go.disabled=!W.log.querySelector('.chip[aria-pressed="true"]');return} 274 send(c.send,c.label);return} 275 const nv=e.target.closest('a.nv');if(nv&&nv.dataset.nav===PAGE){e.preventDefault();return} 276 if(W.dock&&MOB()&&S.ui!=='open'&&!e.target.closest('a'))setUI('open'); 277});}); 278if(DK){ 279 DK.dock.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b||b.closest('.chat-log'))return;const act=b.dataset.act; 280 if(act==='toggle')setUI(S.ui==='open'?'preview':'open');else if(act==='min')setUI(MOB()&&S.ui==='open'?'preview':'min');else if(act==='preview')setUI('preview'); 281 else if(act==='new'){S=fresh();S.ui='open';save();setUI('open');DK.input.focus();}}); 282 addEventListener('keydown',e=>{if(e.key==='Escape'&&S.ui==='open'&&dockShown){setUI('preview');DK.input.focus({preventScroll:true})}}); 283 DK.input.addEventListener('focus',()=>{if(S.ui==='min')setUI('preview')}); 284} 285// landing: the big box 286let syncBox=()=>{}; 287if(LAND){ 288 const ta=VL.input,form=VL.form,ph=form.querySelector('.ask-ph-t'); 289 // empty: no fixed height, so the field fills its grid cell, which the longest example (laid out invisibly) sizes; with text: 290 // it grows past that with the text (the examples stay in the cell, hidden, so starting to type never shrinks the box) 291 syncBox=()=>{form.classList.toggle('has-text',!!ta.value);ta.style.height='auto';if(!ta.value){ta.style.height='';return}
291ta.style.height=Math.min(ta.scrollHeight,innerHeight*.34)+'px'}; 292 ta.addEventListener('input',syncBox); 293 ta.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing){e.preventDefault();form.requestSubmit?form.requestSubmit():form.dispatchEvent(new Event('submit',{cancelable:true}))}}); 294 document.getElementById('land-chips').addEventListener('click',e=>{const b=e.target.closest('[data-send]');if(b)send(b.dataset.send,b.textContent)}); 295 VL.thread.querySelector('[data-act="new"]').addEventListener('click',()=>{S=fresh();save();render();jSync();ta.focus()}); 296 // the rotating examples in the empty box 297 // (they come from the page: build.py lays each one out invisibly in the box too, so the box keeps the height of the longest 298 // and nothing moves when they change; the classes are ph-out/ph-in: a bare "out" picked up g.css's .out grid section 299 // styles, 140px of padding, and made the box jump every 3.4 s) 300 const EX=[...form.querySelectorAll('.ask-ph-g')].map(e=>e.textContent); 301 let k=0;if(EX.length>1&&!RM&&!Q.has('freeze'))setInterval(()=>{if(ta.value||document.hidden||S.msgs.length||LAND.getBoundingClientRect().bottom<60)return;ph.classList.add('ph-out'); 302 setTimeout(()=>{k=(k+1)%EX.length;ph.textContent=EX[k];ph.classList.remove('ph-out');ph.classList.add('ph-in');void ph.offsetWidth;ph.classList.remove('ph-in')},360)},3400); 303 if(Q.get('type')){ta.value=Q.get('type');syncBox();} 304 if(!MOB()&&!Q.has('type'))setTimeout(()=>ta.focus({preventScroll:true}),60); 305 // the mark above the box changes size when the conversation starts: let the drawing re-fit 306 new ResizeObserver(()=>dispatchEvent(new Event('resize'))).observe(LAND.querySelector('.land-mark')); 307} 308// "Talk to the lab" and the other contact buttons start the conversation instead of opening mail 309document.addEventListener('click',e=>{const a=e.target.closest('[data-chat]');if(!a||e.metaKey||e.ctrlKey)return;e.preventDefault(); 310 const menu=document.querySelector('.burger[aria-expanded="true"]');if(menu)menu.click(); 311 // J: from anywhere in the journey, "Talk to the lab" goes back up to the full chat and starts there 312 if(JM){if(scrollY>2)scrollTo({top:0,behavior:RM?'instant':'smooth'});send(a.dataset.chat,null,{noFocus:false});return} 313 if(DK&&S.ui!=='open')setUI(MOB()?'open':'preview');send(a.dataset.chat,null,{noFocus:false});}); 314 315// ---- demos, for screenshots: ?demo=<name> seeds the conversation and applies its action (on the page it belongs to) ---- 316const DEMOS={calls:['Can you help my clinic answer calls?'],followup:['Can you help my clinic answer calls?','and ADROTEAM?'], 317 diff:["What's the difference between ADROSUITE and ADROTEAM?"],realsight:['What is RealSight?'],audit:['How does the AI Audit work?'], 318 about:['Who are you?'],rules:['What do you believe?'],offer:['What products do you have?'],salam:['salam'],price:['How much does ADROSUITE cost?'], 319 'book-mid':['Book an AI Audit','Sam Rivera','[email protected]'], 320 'book-done':['Book an AI Audit','Sam Rivera','[email protected]','+971 50 123 4567','We run three dental clinics and want to see where AI fits.'], 321 'quiz-mid':['Which product fits me?','#q1:1'],quiz:['Which product fits me?','#q1:1','#q2:0,1,2','#q3:0'], 322 'quiz-team':['Which product fits me?','#q1:3','#q2:0,1,6','#q3:5']}; 323const demo=DEMOS[Q.get('demo')]; 324if(demo){S=fresh();let last=null;
325 for(const t of demo){S.msgs.push({r:'u',t:t.startsWith('#q')?tokenLabel(t):t});const res=AG.respond(t,S.st);S.st=res.state;S.msgs.push(agentMsg(res));last=res.action||{};} 326 const lm=S.msgs[S.msgs.length-1];S.ui=Q.get('ui')||(LAND?(JM?'preview':null):(lm.chips&&lm.chips.length>4&&!MOB()?'open':'preview')); 327 // on the conversation's own page, nothing is "opened" any more 328 const target=last.navigate||PAGE; 329 S.pending=(last.highlight||last.quiz)?{highlight:last.highlight||null,quiz:last.quiz||null,instant:true}:null; 330 save(); 331 if(target!==PAGE&&!LAND){const p=new URLSearchParams(location.search);p.delete('demo');location.replace(HREF(target)+'?'+p.toString());} 332}else if(Q.get('ui')&&(!LAND||JM))S.ui=Q.get('ui'); 333 334// ---- start ---- 335if(DK){if(!S.ui)S.ui=S.msgs.length?'preview':'min';if(MOB()&&S.ui==='open'&&!Q.get('ui'))S.ui='preview';DK.dock.dataset.ui=S.ui; 336 if(!demo&&!Q.has('freeze')&&!RM&&!JM)DK.dock.classList.add('in');setUI(S.ui);} 337render(); 338const pend=S.pending||(Q.get('hl')?{highlight:{tint:[Q.get('hl')],mark:[],focus:Q.get('hl')},instant:true}:null); 339jSync(); 340if(pend&&(!LAND||JM)){S.pending=null;save(); 341 const run=()=>{if(pend.quiz&&window.__quizApply)window.__quizApply(pend.quiz);applyHL(pend.highlight,pend.instant||Q.has('freeze'))}; 342 (document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve()).then(()=>setTimeout(run,pend.instant?60:350));} 343if(Q.get('ask')&&!demo)setTimeout(()=>send(Q.get('ask')),400); 344})(); 345 346// every link to another site opens in a new tab, wherever it came from (the model's replies included): a safety net for any 347// link rendered without target="_blank" 348document.addEventListener('click',e=>{const a=e.target.closest&&e.target.closest('a[href]');if(!a||!/^https?:/i.test(a.href)||a.host===location.host||a.target==='_blank')return; 349 a.target='_blank';a.rel='noopener noreferrer';},true);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.