PageSourceSearch

https://adrolab.ai/j-chat.js?v=a880b964

js adrolab.ai collected 2026-10-02 01:10:39 UTC 30,693 bytes, 349 lines download raw bytes

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.