1<!DOCTYPE html> 2<html lang="en" data-theme="dark"> 3<head> 4<meta charset="UTF-8" /> 5<meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6<meta name="facebook-domain-verification" content="gj70oloicvvkb0zf410b994g7d6e14" /> 7<title>Sellton, watch it work on your business</title> 8<link rel="icon" type="image/png" href="/favicon.png" /> 9<link rel="apple-touch-icon" href="/favicon.png" /> 10<meta name="description" content="Drop your website. Sellton shows the 3 companies you should be selling to right now, and exactly how it would win them. No sign-up." /> 11<meta property="og:type" content="website" /> 12<meta property="og:url" content="https://sellton.ai/" /> 13<meta property="og:title" content="Sellton, watch it work on your business" /> 14<meta property="og:description" content="Give it your website. It finds the 3 companies you should be selling to right now, and how it would win them." /> 15<meta property="og:image" content="https://sellton.ai/og-image.png" /> 16<meta property="og:image:width" content="1200" /> 17<meta property="og:image:height" content="630" /> 18<meta name="twitter:card" content="summary_large_image" /> 19<meta name="twitter:title" content="Sellton, watch it work on your business" /> 20<meta name="twitter:description" content="Give it your website. It finds the 3 companies you should be selling to right now, and how it would win them." /> 21<meta name="twitter:image" content="https://sellton.ai/og-image.png" /> 22<link rel="preconnect" href="https://fonts.googleapis.com" /> 23<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 24<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> 25<style> 26:root{ 27 --green:#39B54A;--green-deep:#2c9a3c;--purple:#864AB4;--purple-deep:#6f3a99; 28 --radius:16px;--pill:100px; 29 --font:'Geist','Plus Jakarta Sans','Inter',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; 30 --mono:'JetBrains Mono',ui-monospace,monospace; 31} 32html[data-theme="dark"]{ 33 --bg:#04060c;--bg-soft:#0b0f18;--surface:rgba(255,255,255,.035);--bg-soft2:rgba(255,255,255,.06); 34 --border:rgba(255,255,255,.09);--border-accent:rgba(57,181,74,.4); 35 --text:#f4f7fa;--text-dim:#9aa3b2;--text-faint:#5f6b7a; 36 --accent:var(--green);--accent-deep:var(--green-deep);--on-accent:#04140d; 37 --user-bubble:var(--green);--user-text:#04140d;--logo:url('/logo-white.png'); 38 --scroll-thumb:rgba(255,255,255,.10);--scroll-thumb-hover:rgba(255,255,255,.22); 39} 40html[data-theme="light"]{ 41 --bg:#ffffff;--bg-soft:#faf8fd;--surface:#faf8fd;--bg-soft2:#f3ecfa; 42 --border:rgba(28,23,38,.10);--border-accent:rgba(134,74,180,.4); 43 --text:#1c1726;--text-dim:#5f5870;--text-faint:#9990a6; 44 --accent:var(--purple);--accent-deep:var(--purple-deep);--on-accent:#ffffff; 45 --user-bubble:var(--purple);--user-text:#ffffff;--logo:url('/logo-color.png'); 46 --scroll-thumb:rgba(28,23,38,.14);--scroll-thumb-hover:rgba(28,23,38,.28); 47} 48*{box-sizing:border-box;margin:0;padding:0} 49/* Scrollbars (Borce, 2026-09-28: "look very bad"). The Windows default is a white track with arrow 50 buttons, glaring on the dark theme. Thin, rounded, no buttons, an invisible track; the thumb is 51 faint until the pointer is over the pane, and takes the accent colour under the pointer. Blink 52 and WebKit get the full styling; Firefox, which has no ::-webkit-scrollbar, the standard pair. */ 53::-webkit-scrollbar{width:10px;height:10px} 54::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent} 55::-webkit-scrollbar-button{display:none;width:0;height:0} 56::-webkit-scrollbar-thumb{background-color:var(--scroll-thumb);border:3px solid transparent;border-radius:10px;background-clip:padding-box;min-height:40px} 57:hover::-webkit-scrollbar-thumb{background-color:var(--scroll-thumb-hover)} 58::-webkit-scrollbar-thumb:hover,::-webkit-scrollbar-thumb:active{background-color:var(--accent)} 59@supports not selector(::-webkit-scrollbar){*{scrollbar-width:thin;scrollbar-color:var(--scroll-thumb-hover) transparent}} 60/* 100dvh after 100vh: on iPhone 100vh is the height with the toolbars HIDDEN, so a 100vh page is 61 taller than the screen and the whole page slides under the finger. dvh is the real height. */ 62body{font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;flex-direction:column;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;transition:background .3s,color .3s} 63header{display:flex;align-items:center;justify-content:space-between;
63padding:18px 26px;flex-shrink:0} 64.brand{display:block;height:30px;width:140px;flex-shrink:0;background:var(--logo) left center/contain no-repeat;cursor:pointer} 65.theme-btn{background:var(--surface);border:1px solid var(--border);color:var(--text-dim);width:36px;height:36px;border-radius:var(--pill);cursor:pointer;font-size:15px;transition:all .2s} 66.theme-btn:hover{color:var(--text);border-color:var(--border-accent)} 67.hdr-right{display:flex;align-items:center;gap:18px} 68.hdr-login{display:block;color:var(--text-dim);text-decoration:none;font-size:13px;font-weight:500;transition:color .2s} 69.hdr-login:hover{color:var(--text)} 70 71/* Existing-customer login lives in the header on EVERY size. It used to be a fixed, pulsing 72 bottom-right pill, which sat exactly where the chat composer is once the visitor engages and 73 competed with the reveal for attention. A header link cannot cover anything. */ 74main{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;align-items:center} 75 76/* intro */ 77.introWrap{flex:1;display:flex;flex-direction:column;width:100%} 78.intro{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:24px;max-width:720px;margin:0 auto;width:100%} 79.eyebrow{font-family:var(--mono);font-size:12px;letter-spacing:2.5px;text-transform:uppercase;color:var(--text-faint);display:flex;align-items:center;gap:9px;margin-bottom:24px} 80.eyebrow .pip{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent);animation:pipPulse 1.5s ease-in-out infinite} 81@keyframes pipPulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.75)}} 82.intro h1{font-size:clamp(22px,3.05vw,32px);font-weight:600;line-height:1.32;letter-spacing:-.4px;text-wrap:balance;min-height:2em;max-width:720px} 83.intro h1 .accent{color:var(--accent)} 84.hlw{display:inline-block;will-change:filter,opacity,transform;filter:blur(6px);opacity:0;transform:translateY(2px);transition:filter .85s cubic-bezier(.22,.61,.36,1),opacity .85s cubic-bezier(.22,.61,.36,1),transform .85s cubic-bezier(.22,.61,.36,1)} 85.hlw.is-in{filter:blur(0);opacity:1;transform:translateY(0)} 86.hlw.is-accent{color:var(--accent)} 87.hl-caret{display:inline-block;width:2px;height:.9em;margin-left:2px;background:var(--accent);vertical-align:-.1em;opacity:0;transition:opacity .15s linear} 88.hl-caret.is-visible{opacity:1} 89.hl-caret.is-blinking{animation:hl-caret-blink 1s steps(1,end) infinite} 90@keyframes hl-caret-blink{0%,49%{opacity:1}50%,100%{opacity:0}} 91.headline-reveal.is-fading-out{transition:opacity .5s ease,filter .5s ease;opacity:0;filter:blur(4px)} 92@media (prefers-reduced-motion: reduce){ 93 .hlw{transition:none!important;filter:none!important;opacity:1!important;transform:none!important} 94 .hl-caret{display:none!important} 95 .headline-reveal.is-fading-out{transition:none!important} 96} 97.cursor{display:inline-block;width:3px;height:.95em;background:var(--accent);margin-left:3px;vertical-align:-2px;animation:blink 1s steps(1) infinite} 98@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}} 99.sub{margin-top:22px;color:var(--text-dim);font-size:clamp(15px,1.7vw,18px);line-height:1.55;max-width:560px;opacity:0;transform:translateY(6px);transition:opacity .6s,transform .6s;text-wrap:balance} 100.cap{margin-top:26px;width:100%;max-width:520px;display:flex;gap:10px;opacity:0;transform:translateY(8px);transition:opacity .6s,transform .6s} 101.sub.show,.cap.show,.micro.show{opacity:1;transform:none} 102.cap input{flex:1;min-width:0;padding:16px 18px;border-radius:14px;border:1px solid var(--border);background:var(--bg-soft);color:var(--text);font-size:16px;font-family:var(--font);transition:border-color .2s,box-shadow .2s} 103.cap input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--border-accent)} 104.cap button{flex-shrink:0;padding:0 22px;border-radius:14px;border:none;background:var(--accent);color:var(--on-accent);font-size:15px;font-weight:600;font-family:var(--font);cursor:pointer;display:flex;align-items:center;gap:7px;transition:background .2s,transform .15s} 105.cap button:hover{background:var(--accent-deep)}.cap button:active{transform:scale(.98)} 106.micro{margin-top:14px;font-size:13px;color:var(--text-faint);opacity:0;transition:opacity .6s} 107#cf-turnstile{margin:18px auto 2px;display:flex;justify-content:center;min-height:66px}
108.cap input.pulse{animation:inPulse 1.2s ease-in-out 3} 109@keyframes inPulse{0%,100%{box-shadow:0 0 0 0 var(--border-accent)}50%{box-shadow:0 0 0 5px var(--border-accent)}} 110.caperr{color:#e0556b;font-size:13px;margin-top:12px;min-height:18px} 111 112/* The verified company card. First thing in the panel, ~1s in, while research is still running. */ 113.cocard{display:flex;gap:13px;align-items:center} 114.cocard-logo{width:44px;height:44px;flex:0 0 44px;border-radius:9px;object-fit:cover;border:1px solid var(--border)} 115.cocard-b{min-width:0} 116.cocard-name{font-size:16px;font-weight:600;color:var(--text);line-height:1.25;word-break:break-word} 117.cocard-meta{font-size:13px;color:var(--text-dim);margin-top:3px;line-height:1.45} 118.cocard-rows{margin-top:13px;padding-top:12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:9px} 119/* Label above chips, not beside them: the research panel is a narrow column, and a side label 120 wrapped ("WHAT YOU / DO") while stealing width from the chips it was labelling. */ 121.cocard-row{display:flex;flex-direction:column;gap:6px} 122.cocard-k{font-size:11px;letter-spacing:.4px;text-transform:uppercase;color:var(--text-faint)} 123.cocard-chips{display:flex;flex-wrap:wrap;gap:6px;min-width:0} 124.chip{font-size:12px;line-height:1.35;padding:3px 9px;border-radius:var(--pill);background:var(--bg-soft);border:1px solid var(--border);color:var(--text-dim);white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis} 125.cocard-li{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--text-faint);text-decoration:none;margin-top:10px} 126.cocard-li:hover{color:var(--accent)} 127.icp{display:flex;flex-direction:column;gap:8px} 128.icp-row{display:flex;gap:12px;align-items:baseline;font-size:14px;line-height:1.5} 129.icp-k{flex:0 0 96px;color:var(--text-faint);font-size:12px;letter-spacing:.4px;text-transform:uppercase} 130.icp-v{flex:1;color:var(--text)} 131@media(max-width:640px){.icp-row{flex-direction:column;gap:2px}.icp-k{flex:none}} 132 133/* footer */ 134.foot{flex-shrink:0;display:flex;flex-direction:column;gap:16px;padding:22px 28px 18px;border-top:1px solid var(--border)} 135.foot-brand{display:block;height:22px;width:104px;flex-shrink:0;background:var(--logo) left center/contain no-repeat;opacity:.85;transition:opacity .2s} 136.foot-brand:hover{opacity:1} 137.foot-links{display:flex;gap:22px;flex-wrap:wrap} 138.foot a{color:var(--text-dim);text-decoration:none;font-size:13px;transition:color .2s} 139.foot a:hover{color:var(--accent)} 140.foot-bottom{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;padding-top:15px;border-top:1px solid var(--border)} 141.foot-legal{display:flex;align-items:center;gap:18px;flex-wrap:wrap} 142.foot .copy{color:var(--text-faint);font-size:12px;font-family:var(--mono);white-space:nowrap} 143.foot-social{display:flex;align-items:center;gap:13px} 144.foot-social a{color:var(--text-dim);display:flex;align-items:center} 145.foot-social a:hover{color:var(--accent)} 146.foot-social svg{width:18px;height:18px;fill:currentColor} 147@media(max-width:640px){ 148 .foot{padding:14px 20px 12px;gap:10px} 149 .foot-brand{display:none} 150 .foot-links{gap:11px 16px} 151 .foot-bottom{gap:10px 14px;padding-top:11px} 152} 153 154/* engaged two-pane: research (left) + chat (focus, center) */ 155main.engaged{flex-direction:column;align-items:stretch;overflow:hidden} 156.engaged-wrap{display:none;flex:1;width:100%;min-height:0} 157/* Grid, not flex, because 50-50 has to be exactly 50-50. With two flex children both at 158 `flex:1 1 0` the panes still came out 665/616 â flex basis gets pulled around by the content 159 inside each side. `1fr 1fr` cannot be argued with. */ 160/* The ROW is pinned too. An implicit grid row is `auto`, and an auto row grows to fit whatever 161 isn't itself a scroll box: a long conversation stretched it past the window, the message box 162 went below the fold and the research stopped scrolling (found 2026-09-28 with eight messages; 163 the first 50-50 check had two). minmax(0,1fr) = exactly the space there is. */ 164main.engaged .engaged-wrap{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:minmax(0,1fr);min-height:0} 165/* Even split. The research IS the product demo, so it gets the same room as the conversation
166 rather than a narrow column beside it. min-width:0 on both sides stops a long company name or 167 an unbroken URL from forcing one side wider than its half. */ 168.research-panel{min-width:0;border-right:1px solid var(--border);overflow-y:auto;padding:20px 24px 16px;display:flex;flex-direction:column;gap:11px} 169.rp-hint{font-family:var(--mono);font-size:11px;letter-spacing:1.6px;text-transform:uppercase;color:var(--text-faint)} 170.rp-company{font-size:20px;font-weight:600;letter-spacing:-.3px} 171.rp-summary{font-size:13.5px;line-height:1.55;color:var(--text-dim)} 172.research-panel .card{padding:14px} 173.research-panel .prospect{padding:10px 12px} 174.research-panel .prospect .preason{font-size:12.5px;margin-top:5px} 175.research-panel .btn.cta{position:sticky;bottom:0;margin-top:12px;margin-bottom:0;animation:ctaGlow 2.6s ease-in-out infinite} 176@keyframes ctaGlow{0%,100%{box-shadow:0 -12px 18px 6px var(--bg),0 0 0 0 var(--border-accent)}50%{box-shadow:0 -12px 18px 6px var(--bg),0 0 0 5px var(--border-accent)}} 177@keyframes ctaGlowStatic{0%,100%{box-shadow:0 6px 20px -8px var(--accent),0 0 0 0 var(--border-accent)}50%{box-shadow:0 8px 26px -6px var(--accent),0 0 0 5px var(--border-accent)}} 178.foot-engaged{display:none} 179main.engaged .foot-engaged{display:flex;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0;padding:8px 22px;border-top:1px solid var(--border)} 180.foot-engaged .copy{font-size:11px} 181.foot-engaged .foot-social svg{width:15px;height:15px} 182.chat-main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0} 183.chat-msgs{flex:1;overflow-y:auto;padding:26px 22px;display:flex;flex-direction:column;gap:14px;width:100%;max-width:760px;margin:0 auto} 184.chat-input{border-top:1px solid var(--border);padding:14px 18px;display:flex;gap:10px;align-items:flex-end;width:100%;max-width:760px;margin:0 auto} 185.chat-input textarea{flex:1;resize:none;max-height:120px;padding:13px 15px;border-radius:14px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:15px;line-height:1.45} 186.chat-input textarea:focus{outline:none;border-color:var(--accent)} 187.chat-input textarea:disabled{opacity:.5} 188/* 16px on anything touch. iPhone Safari zooms the whole page in when a field under 16px is tapped, 189 and stays zoomed: the right edge of every bubble, the send button and the onboarding button were 190 cut off and the page panned under the finger (Borce, 2026-09-28). */ 191@media (pointer:coarse){.chat-input textarea{font-size:16px}} 192.new-msg{display:none} 193@media(max-width:820px){ 194 /* NOT the desktop grid, and no scroll box inside another. On a phone the page itself scrolls, as 195 one column: the research at its full height, then the conversation. Two earlier versions got 196 this wrong. The 50-50 grid (2026-09-23) collapsed the research panel to 79px. The next one kept 197 `main` and the message list as their own scroll boxes inside a page that was itself 100vh on 198 iPhone: three scrollers, so the page slid about, the message list clipped its first bubble 199 behind its own edge, and the flex box around them stayed one screen tall while its content ran 200 on, which drew the footer straight across the research (Borce's screenshots, 2026-09-28). */ 201 body.engaged{height:auto;min-height:100vh;min-height:100dvh} 202 main.engaged{flex:none;overflow:visible} 203 main.engaged .engaged-wrap{display:flex;flex-direction:column;flex:none;min-height:auto} 204 .research-panel{width:100%;flex-shrink:0;overflow:visible;border-right:none;border-bottom:1px solid var(--border);padding:16px 16px 18px} 205 /* non-sticky on mobile so it flows after the reveal instead of floating/overlapping the chat */ 206 .research-panel .btn.cta{position:static;margin-top:14px;animation:ctaGlowStatic 2.6s ease-in-out infinite} 207 .chat-main{min-height:0} 208 /* Room at the bottom for the pinned box below, so the last message never sits behind it. */ 209 .chat-msgs{flex:none;overflow:visible;padding:20px 16px calc(var(--composer-h,72px) + 18px)} 210 /* The box to type in is always on screen, wherever they are on the page: pinned to the bottom, 211 above the cookie bar while that is open (--consent-h is set by the script). */ 212 .chat-input{position:fixed;left:0;right:0;bottom:var(--consent-h,0px);max-width:none;z-index:50;background:var(--bg);padding:10px 12px calc(10px + env(safe-area-inset-bottom))} 213 .msg{scroll-margin-top:14px;scroll-margin-bottom:calc(var(--composer-h,72px) + 14px)} 214 /* Only © and two icons; on a phone it is one more thing in the way of the conversation. */ 215 main.engaged .foot-engaged{display:none} 216 /* "Sellton replied": the reveal lands at the top of a long page and the verdict at the bottom. 217 Tapping it goes to the message; it goes away by itself once the message is on screen. */ 218 .new-msg.show{display:flex;align-items:center;gap:6px;position:fixed;left:0;right:0;width:max-content;margin:0 auto;bottom:calc(var(--consent-h,0px) + var(--composer-h,72px) + 10px);z-index:51;padding:9px 16px;border:none;border-radius:var(--pill);background:var(--accent);color:var(--on-accent);font:600 13px var(--font);box-shadow:0 8px 22px -8px rgba(0,0,0,.55);cursor:pointer;animation:rise .3s ease both} 219} 220.msg{display:flex;max-width:88%;animation:rise .4s ease both} 221.msg.assistant{align-self:flex-start}.msg.user{align-self:flex-end} 222/* The onboarding button inside a bubble. The panel button is off-screen on mobile and a long 223 way from the conversation on desktop; when Sellton says "start", the button is right there. */ 224.msg.has-cta{flex-direction:column;align-items:flex-start} 225.msg .btn.cta.inline{display:inline-block;width:auto;margin:8px 0 0;padding:11px 18px;font-size:14px;animation:none;box-shadow:0 0 0 0 transparent} 226.bubble{padding:13px 16px;border-radius:var(--radius);font-size:15px;line-height:1.65;background:var(--surface);border:1px solid var(--border);white-space:pre-wrap} 227.msg.user .bubble{background:var(--user-bubble);color:var(--user-text);border:none} 228@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} 229.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px;animation:rise .5s ease both} 230.card h3{font-size:12px;font-weight:600;color:var(--accent);text-transform:uppercase;letter-spacing:.7px;margin-bottom:11px;font-family:var(--mono)} 231.btn{background:var(--accent);color:var(--on-accent);border:none;border-radius:var(--pill);padding:13px 22px;font-size:15px;font-weight:600;font-family:var(--font);cursor:pointer;transition:transform .15s,background .2s} 232.btn:hover{background:var(--accent-deep)}.btn:active{transform:scale(.98)} 233.btn.cta{display:block;width:100%;text-align:center;text-decoration:none;margin-top:8px;padding:15px;font-size:16px} 234.field{margin-bottom:12px} 235.input{width:100%;padding:13px 15px;border-radius:12px;border:1px solid var(--border);background:var(--bg-soft);color:var(--text);font-size:15px;font-family:var(--font)} 236.input:focus{outline:none;border-color:var(--accent)} 237.consent{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-faint);margin:2px 0 14px} 238.prospects{display:grid;grid-template-columns:1fr;gap:10px;margin-top:6px} 239.prospect{background:var(--bg-soft);border:1px solid var(--border);border-radius:12px;padding:13px 15px;animation:rise .5s ease both} 240.prospect .pname{font-size:14px;font-weight:600} 241.prospect .pweb{font-size:12px;color:var(--accent);font-family:var(--mono);margin-top:2px} 242.prospect .pmeta{font-size:12px;color:var(--text-faint);margin-top:6px} 243.prospect .preason{font-size:13px;color:var(--text-dim);margin-top:6px;line-height:1.5} 244 245/* research worklog (real steps, not a vibe dot) */ 246.worklog{display:flex;flex-direction:column;gap:13px} 247.wstep{display:flex;align-items:center;gap:12px;font-size:14.5px;color:var(--text-faint);transition:color .3s} 248.wstep.active{color:var(--text)}.wstep.done{color:var(--text-dim)} 249.wic{width:18px;height:18px;flex-shrink:0;display:flex;align-items:center;justify-content:center} 250.ring{width:15px;height:15px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite} 251@keyframes spin{to{transform:rotate(360deg)}} 252.hollow{width:12px;height:12px;border:2px solid var(--border);border-radius:50%} 253.check{color:var(--accent);font-size:15px;font-weight:700} 254.scan-counter{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:13px;color:var(--text-dim);margin-bottom:13px;padding-bottom:12px;border-bottom:1px solid var(--border)} 255.scan-counter .sc-num{color:var(--accent);font-weight:500;font-variant-numeric:tabular-nums} 256.scan-counter .sc-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent);animation:pipPulse 1.1s ease-in-out infinite;flex-shrink:0} 257 258.typing{display:inline-flex;gap:4px;padding:14px 16px} 259.typing span{width:7px;height:7px;border-radius:50%;background:var(--text-faint);animation:bounce 1.2s infinite} 260.typing span:nth-child(2){animation-delay:.15s}.typing span:nth-child(3){animation-delay:.3s} 261@keyframes bounce{0%,80%,100%{transform:scale(.6);opacity:.4}40%{transform:scale(1);opacity:1}}
262.err{color:#e0556b;font-size:13px;margin-top:8px} 263 264.send{width:44px;height:44px;flex-shrink:0;border-radius:50%;border:none;background:var(--accent);color:var(--on-accent);font-size:18px;cursor:pointer;transition:background .2s} 265.send:hover{background:var(--accent-deep)}.send:disabled{opacity:.4;cursor:not-allowed} 266 267/* cookie consent bar (gates analytics) */ 268.consent-bar{position:fixed;bottom:0;left:0;right:0;display:none;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap;padding:14px 20px;background:var(--bg-soft);border-top:1px solid var(--border);z-index:60;font-size:13px;color:var(--text-dim)} 269.consent-bar .ctext{max-width:560px} 270.consent-bar a{color:var(--accent);text-decoration:none} 271.consent-bar button{border:none;border-radius:var(--pill);padding:8px 16px;font-size:13px;font-weight:600;font-family:var(--font);cursor:pointer} 272.consent-accept{background:var(--accent);color:var(--on-accent)} 273.consent-decline{background:var(--surface);color:var(--text-dim);border:1px solid var(--border)} 274</style> 275</head> 276<body> 277<header> 278 <a class="brand" href="https://sellton.ai" aria-label="Sellton â home"></a> 279 <div class="hdr-right"> 280 <a class="hdr-login" href="https://app.sellton.ai">Log in</a> 281 <button class="theme-btn" id="themeBtn" aria-label="Toggle light/dark">â</button> 282 </div> 283</header> 284 285 286<main id="main"> 287 <div class="introWrap" id="introWrap"> 288 <div class="intro" id="intro"> 289 <div class="eyebrow"><span class="pip"></span>Live · This is Sellton talking, not a landing page</div> 290 <h1 id="introText"><span class="cursor"></span></h1> 291 <div class="sub" id="introSub">Give me your business website. I'll find 3 companies you should be selling to right now, and show you exactly why.</div> 292 <div class="cap" id="introCap"> 293 <input id="inDomain" placeholder="yourcompany.com" autocomplete="url" /> 294 <button id="goBtn">Show me <span>â</span></button> 295 </div> 296 <div id="cf-turnstile"></div> 297 <div class="micro" id="introMicro">No sign-up.</div> 298 <div class="caperr" id="capErr"></div> 299 </div> 300 <footer class="foot" id="foot"> 301 <a class="foot-brand" href="/product" target="_blank" rel="noopener" aria-label="Sellton"></a> 302 <nav class="foot-links"> 303 <a href="/product" target="_blank" rel="noopener">Product</a> 304 <a href="/pricing" target="_blank" rel="noopener">Pricing</a> 305 <a href="/about" target="_blank" rel="noopener">About</a> 306 <a href="/anti-workflows" target="_blank" rel="noopener">Anti Workflows</a> 307 <a href="/blog" target="_blank" rel="noopener">Blog</a> 308 <a href="/gtm-calculator" target="_blank" rel="noopener">GTM calculator</a> 309 <a href="/compare" target="_blank" rel="noopener">Compare</a> 310 </nav> 311 <div class="foot-bottom"> 312 <div class="foot-legal"> 313 <span class="copy">
313© Sellton 2026</span> 314 <a href="/privacy" target="_blank" rel="noopener">Privacy</a> 315 <a href="/terms" target="_blank" rel="noopener">Terms</a> 316 </div> 317 <div class="foot-social"> 318 <a href="https://linkedin.com/company/selltonai" target="_blank" rel="noopener" aria-label="LinkedIn"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg></a> 319 <a href="https://www.instagram.com/selltonai/" target="_blank" rel="noopener" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg></a> 320 </div> 321 </div> 322 </footer> 323 </div> 324 <div class="engaged-wrap" id="engagedWrap"> 325 <aside class="research-panel" id="researchPanel"><div class="rp-hint">Researchingâ¦</div></aside> 326 <section class="chat-main"> 327 <div class="chat-msgs" id="chatMsgs"></div> 328 <button class="new-msg" id="newMsg" type="button">Sellton replied â</button> 329 <div class="chat-input" id="chatInput"> 330 <textarea id="composer" rows="1" placeholder="Ask me anythingâ¦" disabled></textarea> 331 <button class="send" id="send" aria-label="Send" disabled>â</button> 332 </div> 333 </section> 334 </div> 335 <footer class="foot foot-engaged" id="footEngaged"> 336 <span class="copy">
336© Sellton 2026</span> 337 <div class="foot-social"> 338 <a href="https://linkedin.com/company/selltonai" target="_blank" rel="noopener" aria-label="LinkedIn"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg></a> 339 <a href="https://www.instagram.com/selltonai/" target="_blank" rel="noopener" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg></a> 340 </div> 341 </footer> 342</main> 343 344<div class="consent-bar" id="consentBar"> 345 <span class="ctext">We use cookies for analytics to improve Sellton. See our <a href="/privacy" target="_blank" rel="noopener">privacy policy</a>.</span> 346 <button class="consent-accept" id="consentAccept">Accept</button> 347 <button class="consent-decline" id="consentDecline">Decline</button> 348</div> 349
350<script> 351const API=""; 352const $=id=>document.getElementById(id); 353const main=$("main"),introWrap=$("introWrap"),researchPanel=$("researchPanel"),chatMsgs=$("chatMsgs"),composer=$("composer"),sendBtn=$("send"); 354 355const themeBtn=$("themeBtn"); 356const savedTheme=localStorage.getItem("sellton_theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"); 357document.documentElement.setAttribute("data-theme",savedTheme); 358themeBtn.onclick=()=>{const n=document.documentElement.getAttribute("data-theme")==="dark"?"light":"dark";document.documentElement.setAttribute("data-theme",n);localStorage.setItem("sellton_theme",n);}; 359 360let sid=localStorage.getItem("sellton_sid"); 361if(!sid){sid="w-"+Math.random().toString(36).slice(2)+Date.now().toString(36);localStorage.setItem("sellton_sid",sid);} 362let state={domain:"",email:"",companyCard:null}; 363let CFG=null, TS_TOKEN=""; 364const scrollDown=()=>researchPanel.scrollTop=researchPanel.scrollHeight; 365const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;}; 366const esc=s=>(s||"").replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c])); 367const wait=ms=>new Promise(r=>setTimeout(r,ms)); 368 369// typewriter intro â Sellton speaks live, rotating a few lines, blinking cursor 370// The homepage hooks. Each is a different frame on the same mechanic â the input, the button and 371// what happens when you press it never change, only the reason we give you to press it. 372// `t` is the headline, `a` the phrase the reveal accents (must be a substring of `t`), `sub` the 373// line above the input. The micro under the button stays fixed for all of them. 374// CHANGING A HOOK'S WORDS? GIVE IT A NEW `id`. Every submission is attributed to the id on screen, 375// and the admin reads the live headlines straight from this array: reusing an id blends the old 376// copy's results into the new copy's. The retired set (h1-h5, h7-h10, replaced 2026-09-18) keeps 377// its history in the admin. tests/test_landing_hooks.py checks ids, accents and wording. 378// "h6" is the only one that can go stale: its numbers come from /web/config, which reads the same 379// two constants the marketing site prints on /pricing. Built at reveal time so a slow config lands. 380const VARIANTS=[ 381 {id:"accounts-now", t:"I find the accounts worth approaching right now, and rule out the rest.", 382 a:"worth approaching right now", 383 sub:"Drop your website. You'll see who fits and why before anyone sends a thing."}, 384 {id:"silence-signal", t:"Silence is a signal. I notice when a deal goes quiet, and act.", 385 a:"Silence is a signal", 386 sub:"Nothing sits in your pipeline waiting. Drop your website to see the first step."}, 387 {id:"best-hire", t:"Talk to me like your best sales hire. I'll answer like one.", 388 a:"your best sales hire", 389 sub:"Drop your website, then ask me who to sell to and why."}, 390 {id:"crm-context", t:"Your CRM updates itself, with the context of every deal.", 391 a:"the context of every deal", 392 sub:"Every reply read, every next step written. Nobody on your team types it in."}, 393 {id:"qualified-first", t:"Every account qualified before your team spends a minute on it.", 394 a:"qualified before your team spends a minute on it", 395 sub:"Matched to your case studies, your business and your GTM play."}, 396 {id:"h6", t:"Founding customer {next} of {total}. Deployment included.", 397 a:"Deployment included", 398 sub:"{left} spots left. Drop your website and see if you fit."}, 399 {id:"tam-daily", t:"I follow your market and your TAM every day, and tell you where revenue is moving.", 400 a:"where revenue is moving", 401 sub:"Start with what I understand about your business. Drop your website."}, 402 {id:"dont-configure", t:"Don't configure me. Tell me how you sell, and I'll run it.", 403 a:"I'll run it",
404 sub:"A twenty-minute conversation replaces the setup. It starts with your website."}, 405 {id:"brief-written", t:"Walk into every call with the brief already written.", 406 a:"the brief already written", 407 sub:"Who they are, what they said, what they need. Start by dropping your website."}, 408 {id:"lists-2000s", t:"List building is so 2000s. I rank your whole market by who's ready now.", 409 a:"who's ready now", 410 sub:"Drop your website and see where I'd start."}, 411 {id:"whole-lane", t:"Finds the accounts. Qualifies them. Works the replies. Updates the CRM.", 412 a:"Works the replies. Updates the CRM.", 413 sub:"One system for the whole lane. Drop your website to watch the first part live."}, 414]; 415const HOOK_MICRO="No sign-up."; 416// Fill #6's real numbers. Until /web/config lands there is nothing true to print, so that hook is 417// skipped rather than shown with a placeholder â a made-up count is worse than one fewer hook (G8). 418function hookReady(v){ 419 if(v.t.indexOf("{")<0)return true; 420 return !!(CFG&&typeof CFG.founding_taken==="number"&&typeof CFG.founding_total==="number"); 421} 422function hookFill(s){ 423 if(!CFG)return s; 424 return s.replace(/\{next\}/g,String((CFG.founding_taken||0)+1)) 425 .replace(/\{total\}/g,String(CFG.founding_total||0)) 426 .replace(/\{left\}/g,String((CFG.founding_total||0)-(CFG.founding_taken||0))); 427} 428// Start somewhere random and walk the whole ring. Starting at #1 every time would credit it with 429// every visitor who drops their domain in the first few seconds, which is most of them â the order 430// would decide the winner instead of the copy. 431let vIdx=Math.floor(Math.random()*VARIANTS.length), cycling=true, revealed=false; 432// Headline reveal: overlapped blur-resolve â words dissolve from blur to sharp with heavy 433// overlap, so the line flows into focus (not a word-by-word typewriter). Design by Sonnet. 434const HL_WORD_MS=850, HL_STAGGER_MS=105, HL_CARET_MS=220, HOLD_MS=6000, FADE_OUT_MS=500; 435const _hlTimers=new WeakMap(); 436function _hlClear(node){const t=_hlTimers.get(node);if(t){t.forEach(clearTimeout);_hlTimers.set(node,[]);}} 437function _reduced(){return window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;} 438function _tok(text,accent){ 439 const toks=[];let as=-1,ae=-1; 440 if(accent){as=text.indexOf(accent);if(as!==-1)ae=as+accent.length;} 441 const re=/(\s+)|(\S+)/g;let m,pos=0; 442 while((m=re.exec(text))!==null){ 443 const chunk=m[0],isSpace=m[1]!==undefined,cs=pos,ce=pos+chunk.length;pos=ce; 444 toks.push({word:chunk,isSpace,accent:!isSpace&&as!==-1&&cs<ae&&ce>as}); 445 } 446 return toks; 447} 448function revealHeadline(node,text,accent,onDone){ 449 _hlClear(node);node.classList.remove('is-fading-out');node.innerHTML='';node.classList.add('headline-reveal'); 450 const toks=_tok(text,accent); 451 if(_reduced()){ 452 toks.forEach(t=>{if(t.isSpace){node.appendChild(document.createTextNode(t.word));}else{const s=document.createElement('span');s.className='hlw is-in'+(t.accent?' is-accent':'');s.innerHTML=esc(t.word);node.appendChild(s);}}); 453 if(typeof onDone==='function')onDone();return; 454 } 455 const caret=document.createElement('span');caret.className='hl-caret';node.appendChild(caret); 456 const words=[]; 457 toks.forEach(t=>{ 458 if(t.isSpace){node.insertBefore(document.createTextNode(t.word),caret);} 459 else{const s=document.createElement('span');s.className='hlw'+(t.accent?' is-accent':'');s.innerHTML=esc(t.word);node.insertBefore(s,caret);words.push(s);} 460 }); 461 caret.classList.add('is-visible'); 462 const timers=[]; 463 words.forEach((s,i)=>timers.push(setTimeout(()=>s.classList.add('is-in'),i*HL_STAGGER_MS))); 464 const total=(words.length-1)*HL_STAGGER_MS+HL_WORD_MS; 465 timers.push(setTimeout(()=>{caret.classList.add('is-blinking');if(typeof onDone==='function')onDone();},total)); 466 timers.push(setTimeout(()=>caret.classList.add('is-blinking'),total+HL_CARET_MS)); 467 _hlTimers.set(node,timers); 468} 469function settleHeadline(node,text,accent){ 470 _hlClear(node);node.classList.remove('is-fading-out');node.innerHTML='';node.classList.add('headline-reveal'); 471 _tok(text,accent).forEach(t=>{if(t.isSpace){node.appendChild(document.createTextNode(t.word));}else{const s=document.createElement('span');s.className='hlw is-in'+(t.accent?' is-accent':'');
471s.innerHTML=esc(t.word);node.appendChild(s);}}); 472} 473let hlHold,hlFade; 474// Walk forward to the next hook we can honestly render (skips #6 until its real numbers exist). 475function nextHookIdx(from){ 476 for(let i=1;i<=VARIANTS.length;i++){ 477 const j=(from+i)%VARIANTS.length; 478 if(hookReady(VARIANTS[j]))return j; 479 } 480 return from; 481} 482function typeIntro(){ 483 const node=$("introText"); 484 if(!hookReady(VARIANTS[vIdx]))vIdx=nextHookIdx(vIdx); 485 (function cycle(){ 486 if(!cycling)return; 487 const v=VARIANTS[vIdx]; 488 CURRENT_HOOK=v.id; // what a domain dropped right now gets attributed to 489 $("introSub").textContent=hookFill(v.sub); 490 revealHeadline(node,hookFill(v.t),v.a,function(){ 491 $("introSub").classList.add("show"); // every pass, not just the first â the sub swaps too 492 if(!revealed){ 493 revealed=true; 494 setTimeout(()=>{$("introCap").classList.add("show");$("introMicro").classList.add("show");$("inDomain").focus();startIdleNudge();},300); 495 } 496 if(!cycling)return; 497 hlHold=setTimeout(()=>{ 498 if(!cycling)return; 499 node.classList.add('is-fading-out'); 500 $("introSub").classList.remove("show"); 501 hlFade=setTimeout(()=>{if(!cycling)return;vIdx=nextHookIdx(vIdx);cycle();},FADE_OUT_MS); 502 },HOLD_MS); 503 }); 504 })(); 505} 506function stopCycling(){ 507 if(!cycling)return;cycling=false; 508 clearTimeout(hlHold);clearTimeout(hlFade); 509 settleHeadline($("introText"),VARIANTS[vIdx].t,VARIANTS[vIdx].a); 510} 511// pure-code nudge: 5s after the input appears with nothing typed, gently pulse the field 512// (no copy swap â we want the real visitor's own site, not competitor/ghost drops) 513let idleNudged=false; 514function startIdleNudge(){ 515 setTimeout(()=>{ 516 if(idleNudged||main.classList.contains("engaged")||$("inDomain").value.trim())return; 517 idleNudged=true; 518 $("inDomain").classList.add("pulse");$("inDomain").focus(); 519 },5000); 520} 521 522$("goBtn").onclick=submitDomain; 523$("inDomain").addEventListener("keydown",e=>{if(e.key==="Enter")submitDomain();}); 524$("inDomain").addEventListener("input",()=>{$("inDomain").classList.remove("pulse");stopCycling();autoRan=true;}); 525 526// ---- Deep-link handoff: sellton.ai/?domain= or #domain= prefills + auto-runs research ---- 527// sanitize to a bare hostname (mirrors server normalize_domain); junk/XSS reduce to "" (no auto-run). 528let researchStarted=false, autoRan=false; 529function sanitizeDomainInput(raw){ 530 if(!raw)return""; 531 let s=String(raw).trim().toLowerCase(); 532 if(s.indexOf("@")>-1)s=s.split("@").pop(); 533 s=s.replace(/^[a-z]+:\/\//,"").split("/")[0].split("?")[0].split("#")[0].split(":")[0]; 534 if(s.indexOf("www.")===0)s=s.slice(4); 535 s=s.replace(/^\.+|\.+$/g,""); 536 if(!s||s.length>253)return""; 537 if(!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(s))return""; 538 return s; 539} 540function readDomainParam(){ 541 let raw=""; 542 try{ 543 raw=new URLSearchParams(location.search).get("domain")||""; 544 if(!raw&&location.hash)raw=new URLSearchParams(location.hash.replace(/^#/,"")).get("domain")||""; 545 }catch(e){raw="";} 546 return sanitizeDomainInput(raw); 547} 548// The GTM calculator hands its numbers over via localStorage. Both surfaces are served from 549// sellton.ai (nginx splits by path), so they share an origin and no URL payload is needed â which 550// also keeps the figures out of logs and referrers. Read once, then clear: it is a handoff, not 551// state, and a stale snapshot would have the chat quoting numbers from a previous visit. 552// Which hero hook this visitor acted on. The hero cycles all ten, so what we attribute is the hook 553// that was ON SCREEN when they dropped their domain â the line they were actually reading â not the 554// one that happened to load first. Frozen at that moment so the later email capture reports the same 555// hook, and the whole chain (hero â research â email â engaged â onboarding) reads per hook. 556// A visitor arriving from the marketing widget never sees a hook, so those are tagged "handoff" 557// rather than credited to whichever line the cycle was on. 558let CURRENT_HOOK=VARIANTS[vIdx].id, HOOK_FROZEN=""; 559function hookId(){ return HOOK_FROZEN||CURRENT_HOOK; } 560function freezeHook(){ 561 HOOK_FROZEN=AUTO_DOMAIN?"handoff":CURRENT_HOOK; 562 try{localStorage.setItem("sellton_hook",HOOK_FROZEN);}catch(e){} // private mode: attribution lost, flow is not 563 return HOOK_FROZEN; 564} 565 566// Read once and CACHE; clear localStorage only after a capture actually succeeds. Destroying the 567// snapshot on read meant a rate-limited or failed email submit silently lost the visitor's 568// calculator figures, so the retry sent nothing and the chat opened blind to their numbers. 569let CALC_SNAPSHOT; 570function readCalcHandoff(){ 571 if(CALC_SNAPSHOT!==undefined)return CALC_SNAPSHOT; 572 CALC_SNAPSHOT=null; 573 try{ 574 const raw=localStorage.getItem("sellton_calc"); 575 if(raw){const d=JSON.parse(raw);
575if(d&&typeof d==="object"&&d.stack_cost)CALC_SNAPSHOT=d;} 576 }catch(e){} 577 return CALC_SNAPSHOT; 578} 579function clearCalcHandoff(){try{localStorage.removeItem("sellton_calc");}catch(e){}} 580const AUTO_DOMAIN=readDomainParam(); 581if(AUTO_DOMAIN)$("inDomain").value=AUTO_DOMAIN; // prefill for visibility while any token resolves 582function maybeAutoRun(){ 583 if(autoRan||researchStarted||!AUTO_DOMAIN)return; 584 if(main.classList.contains("engaged"))return; 585 if(!CFG)return; // wait for /web/config 586 if(CFG.turnstile_site_key&&!TS_TOKEN)return; // never submit without a real token â no bypass 587 autoRan=true; 588 if($("inDomain").value.trim()!==AUTO_DOMAIN)$("inDomain").value=AUTO_DOMAIN; 589 submitDomain(); 590} 591 592function submitDomain(){ 593 if(researchStarted)return; // idempotent â never start research twice 594 const domain=$("inDomain").value.trim();const err=$("capErr"); 595 stopCycling(); 596 if(!domain){err.textContent="Drop your website to start.";return;} 597 if(CFG&&CFG.turnstile_site_key&&!TS_TOKEN){err.textContent="Please complete the check below.";return;} 598 researchStarted=true; // commit only after guards pass (retry stays possible) 599 freezeHook(); // credit the hook they were reading, not a later one 600 // companyCard is cleared with the rest: a visitor who retries with a DIFFERENT domain must never 601 // be shown the previous company's card. That is the same "confident wrong company" failure the 602 // exact-domain check on the server exists to prevent, arriving by another door. 603 err.textContent="";state.domain=domain;state.email="";state.companyCard=null; 604 // fresh chat session per URL â never bleed a previous company's conversation into this one 605 sid="w-"+Math.random().toString(36).slice(2)+Date.now().toString(36);localStorage.setItem("sellton_sid",sid); 606 track('lead_capture',{domain:domain}); 607 introWrap.style.transition="opacity .4s";introWrap.style.opacity="0"; 608 setTimeout(()=>{introWrap.style.display="none";main.classList.add("engaged");document.body.classList.add("engaged");startResearch(domain);},400); 609} 610 611// Desktop: the message list is its own scroll box, kept at the bottom. Phone: the PAGE scrolls 612// (see the max-width:820px CSS), so nothing here moves anything; a message is brought into view 613// only when the visitor has just done something (showMsg), never while they are reading. 614const STACKED=matchMedia("(max-width:820px)"); 615const chatScroll=()=>{if(!STACKED.matches)chatMsgs.scrollTop=chatMsgs.scrollHeight;}; 616function showMsg(m,where){ 617 if(!m)return; 618 if(!STACKED.matches){chatScroll();return;} 619 m.scrollIntoView({block:where||"end",behavior:"smooth"}); 620} 621// The pinned box's height, for the page's bottom padding and the pill's position. 622const chatInputEl=$("chatInput"); 623if(window.ResizeObserver)new ResizeObserver(()=>document.documentElement.style.setProperty("--composer-h",chatInputEl.offsetHeight+"px")).observe(chatInputEl); 624// "Sellton replied" (phone only): a message that arrives while the visitor is reading the research, 625// far above it. Hidden again as soon as the message is actually on screen. 626const newMsgBtn=$("newMsg");let flaggedMsg=null; 627const msgSeen=window.IntersectionObserver?new IntersectionObserver(es=>{for(const e of es){ 628 if(e.isIntersecting&&e.target===flaggedMsg){newMsgBtn.classList.remove("show");msgSeen.unobserve(e.target);flaggedMsg=null;} 629}},{rootMargin:"0px 0px -90px 0px"}):null; 630function flagNew(m){ 631 if(!STACKED.matches||!m||!msgSeen)return; 632 if(flaggedMsg)msgSeen.unobserve(flaggedMsg); 633 flaggedMsg=m;newMsgBtn.classList.add("show");msgSeen.observe(m); 634} 635newMsgBtn.onclick=()=>{if(flaggedMsg)flaggedMsg.scrollIntoView({block:"start",behavior:"smooth"});}; 636// The chat hands out three real links: the GTM calculator, a /vs comparison page, and the 637// build-session calendar. They were escaped into dead text, so the visitor had to select and paste 638// a URL â worst on the booking fallback, which is the last thing we offer someone hesitating. 639// Escape FIRST, then linkify only our own hosts: nothing a model writes can inject markup, and a 640// hallucinated third-party URL still renders as inert text. 641const _LINK_OK=/^https:\/\/(?:sellton\.ai|app\.sellton\.ai|cal\.com)\//i; 642function linkify(escaped){ 643 return escaped.replace(/https:\/\/[^\s<]+[^\s<.,;:!?)]/g, function(u){ 644 if(!_LINK_OK.test(u))return u; 645 return '<a href="'+u+'" target="_blank" rel="noopener noreferrer">'+u+'</a>'; 646 }); 647} 648function addChatMsg(role,text){const m=el("div","msg "+role);const b=el("div","bubble");b.innerHTML=linkify(esc(text));m.appendChild(b);chatMsgs.appendChild(m);chatScroll();return m;} 649function addChatTyping(){const m=el("div","msg assistant");m.appendChild(el("div","bubble typing","<span></span><span></span><span></span>"));chatMsgs.appendChild(m);chatScroll();return m;} 650 651// research worklog, descriptive steps, spinner â check 652async function startResearch(domain){ 653 // One line that names the payoff, then NOTHING that isn't a real result.
654 // The old copy promised "~5-7 seconds" and dripped "Almost there" at 11.5s, while a fresh run 655 // actually takes 35-50s (measured). Filler during a wait is exactly what "show, don't tell" is 656 // against, and a countdown that lies is worse than no countdown. 657 // Say how long, and what is being built, so they know to wait (Borce, 2026-09-29). Same sentence 658 // as funnel_beats.RESEARCH_WAIT, which WhatsApp sends: change both. 659 addChatMsg("assistant","Reading "+domain+" now. Give me up to a minute: I'm reading your site properly, working out who buys from you and building your strategy, then you'll see the 3 companies I'd go after first."); 660 const researchTyping=addChatTyping(); 661 662 // The profile lands ~7s in, well before the ICP search finishes. Surface it the moment it 663 // exists: the first thing the visitor sees is us getting THEIR business right, which is worth 664 // more than any progress copy. Read-only endpoint â it never starts research. 665 let profileShown=false,cardShown=false; 666 const profilePoll=setInterval(async()=>{ 667 if(profileShown)return; 668 try{ 669 const r=await fetch(API+"/web/research-status?domain="+encodeURIComponent(domain)); 670 const d=await r.json(); 671 // The card is fetched before research even starts, so it lands ~0.6s in while the stage is 672 // still "none". Render it at whatever stage it appears; if it is still absent once the 673 // profile arrives, this visitor simply has no card and polling can stop as it always did. 674 if(!cardShown&&d.company_card){cardShown=true;state.companyCard=d.company_card;renderCompanyCard(d.company_card);} 675 if(d.stage==="profiling"&&d.profile){ 676 // The gist, not the dossier. Pasting both fields whole produced a 444-character bubble for 677 // Textil Solution (2026-09-25) â the full services list and every country in brackets â 678 // while the "Who buys" row directly below already shows the target market in full. 679 const what=gist((d.profile.services||"").trim(),70); 680 const who=gist((d.profile.target_market||"").trim(),70); 681 let line=""; 682 if(what&&who)line="You sell "+what+" to "+who+"."; 683 else if(d.profile.company_summary)line=d.profile.company_summary.trim(); 684 if(line){ 685 profileShown=true;clearInterval(profilePoll); 686 addChatMsg("assistant",line); 687 renderIcpBlock(d.profile); // real output at ~7s, long before the 3 companies land 688 } 689 }else if(d.stage==="research_shown"){clearInterval(profilePoll);} 690 }catch(e){/* polling is a nicety; never let it break the wait */} 691 },1200); 692 const drips=[]; 693 const steps=["Reading "+domain,"Figuring out what you sell, and to whom","Scanning the market for strong-fit c
693ompanies","Choosing the 3 you should talk to first"]; 694 const card=el("div","card"); 695 const counter=el("div","scan-counter");counter.innerHTML='<span class="sc-dot"></span><span class="sc-num">Reading the site</span>'; 696 const log=el("div","worklog"); 697 card.appendChild(counter);card.appendChild(log);researchPanel.innerHTML="";researchPanel.appendChild(card);scrollDown(); 698 // Was an animated "3,800-5,600 companies scanned" â a number nothing counted, on the page whose 699 // whole promise is "judge me on output". Replaced with the phases that are genuinely running. 700 // Motion without a fabricated metric; the pulsing dot carries the liveness. 701 const scNode=counter.querySelector(".sc-num"); 702 const PHASES=["Reading the site","Working out what you sell","Working out who buys it", 703 "Searching for companies that match","Checking each one against you", 704 "Picking the 3 to open with"]; 705 let phase=0; 706 const scTimer=setInterval(()=>{ 707 if(phase<PHASES.length-1){phase++;scNode.textContent=PHASES[phase];} 708 },6000); 709 const nodes=steps.map(s=>{const n=el("div","wstep");n.innerHTML='<span class="wic"><span class="hollow"></span></span><span>'+esc(s)+'</span>';log.appendChild(n);return n;}); 710 let cur=-1; 711 const done=i=>{nodes[i].classList.remove("active");nodes[i].classList.add("done");nodes[i].querySelector(".wic").innerHTML='<span class="check">â</span>';}; 712 const activate=i=>{if(i>0)done(i-1);nodes[i].classList.add("active");nodes[i].querySelector(".wic").innerHTML='<span class="ring"></span>';cur=i;}; 713 activate(0); 714 const timer=setInterval(()=>{if(cur<nodes.length-1)activate(cur+1);},1700); 715 716 let d=null,error=false; 717 try{ 718 const [res]=await Promise.all([ 719 fetch(API+"/web/capture",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain,turnstile_token:TS_TOKEN,hook_id:hookId()})}).then(r=>r.json()), 720 wait(2400) // min theater time so the work is visible even on cache hits 721 ]); 722 d=res; 723 // On a research cache hit capture returns before the poll has had a chance to fire, so take 724 // the card from here too. The reveal below needs it either way. 725 if(d&&d.company_card)state.companyCard=d.company_card; 726 }catch(e){error=true;} 727 drips.forEach(clearTimeout);clearInterval(profilePoll);clearInterval(timer);clearInterval(scTimer);scNode.textContent="Done"; 728 for(let i=0;i<nodes.length;i++)done(i); 729 await wait(450); 730 researchTyping.remove(); 731 732 if(error){researchPanel.innerHTML='<div class="rp-hint">Something hiccuped</div>';addChatMsg("assistant","Something hiccuped on our side, refresh and try again?");return;} 733 // The off-ramp line is the server's, so the chat can replay exactly what was said here. 734 if(d.stage==="declined"){researchPanel.innerHTML='<div class="rp-hint">Not a fit</div>';addChatMsg("assistant",d.opening_message||"Looking at this, Sellton's built for B2B companies running outbound, so it may not be your fit. If I've got that wrong, tell me more.");enableChat();return;} 735 // A failed bot check is not a failed website. It used to fall into the catch-all below, so the 736 // visitor was told we couldn't read their site â and, because researchStarted was already true, 737 // submitDomain() was blocked for the rest of the page's life. One expired token bricked the page. 738 // Something that isn't a web address goes back to the hero too: the chat only serves a domain 739 // that was actually researched, so opening it here would answer them with a refusal. 740 if(d.stage==="rejected"||d.stage==="rate_limited"||d.stage==="invalid_domain"){resetToHero(d.stage);return;} 741 if(d.stage!=="research_shown"){researchPanel.innerHTML='<div class="rp-hint">Something hiccuped</div>';addChatMsg("assistant","Something hiccuped on our side, refresh and try again?");return;} 742 renderReveal(d); 743} 744 745// Who their best customer is, from the profile step. Shown the MOMENT it exists rather than held 746// back until the ICP search finishes ~40s later â the visitor reads something true about their own 747// business while the rest is still running, which is what makes the wait survivable. 748// Only fields the engine actually returned are rendered (never "Unknown"). 749// Two different things, kept apart. Industry and size describe THEM; who buys and why describe 750// their CUSTOMER. They used to share one box titled "Who your best customer is", so IMA read that 751// its best customer was "B2B Sales & Revenue Operations Consulting, small (11-50)" â itself 752// (Borce, 2026-09-28). 753const _known=r=>r[1]&&String(r[1]).trim()&&String(r[1]).trim().toLowerCase()!=="unknown"; 754function icpRows(p){ 755 return [["Who buys",p.target_market],["Why they buy",p.pain_points]].filter(_known); 756} 757function rowsCard(title,rows){ 758 if(!rows.length)return ""; 759 return `<div class="card"><h3>${esc(title)}</h3><div class="icp">`+ 760 rows.map(r=>`<div class="icp-row"><span class="icp-k">${esc(r[0])}</span><span class="icp-v">${esc(String(r[1]))}</span></div>`).join("")+ 761 `</div></div>`; 762} 763function icpHtml(p){return rowsCard("Who your best customer is",icpRows(p));} 764// Their own industry and size, from research â only when there is no verified company card, which 765// already shows both (and from a company database rather than a reading of the site). 766function companyFactsHtml(p){ 767 if(state.companyCard)return ""; 768 return rowsCard("Your company",[["Industry",p.industry],["Size",p.company_
768size]].filter(_known)); 769} 770// The first natural clause of a long profile field, for a chat line. Drops everything after a 771// semicolon and every parenthetical, then cuts before a relative clause ("requiring", "seeking", 772// "who"...) or at the last comma that fits. Never cuts a word, never adds an ellipsis. 773// funnel_beats.gist is the same rule on the server (the verdict line): change both together. 774// "B2B apparel brands and manufacturers in Europe (Germany, ...) requiring protective ...; mid..." 775// -> "B2B apparel brands and manufacturers in Europe" 776function gist(s,max){ 777 s=String(s||"").split(";")[0].replace(/\s*\([^)]*\)/g,"").replace(/\s+/g," ").trim().replace(/[.,]+$/,""); 778 if(s.length<=max)return s; 779 const rel=s.slice(20).search(/\s(requiring|seeking|looking|needing|who|that|which|including|such as)\s/i); 780 if(rel>=0&&rel+20<=max)return s.slice(0,rel+20).replace(/[\s,]+$/,""); 781 const comma=s.lastIndexOf(", ",max); 782 if(comma>20)return s.slice(0,comma); 783 const sp=s.lastIndexOf(" ",max); 784 return (sp>20?s.slice(0,sp):s.slice(0,max)).replace(/[\s,]+$/,""); 785} 786 787// Their own company, verified against a company database rather than inferred from the page. 788// Only fields that actually came back are rendered â never a blank, never a guess. A visitor whose 789// domain isn't in the database (about a quarter of them) simply never sees this, and everything 790// else behaves exactly as before. 791function companyCardHtml(c){ 792 if(!c||!c.name)return ""; 793 const bits=[]; 794 if(c.industry)bits.push(esc(c.industry)); 795 if(c.staff){const n=Number(c.staff);bits.push(esc(n.toLocaleString("en-US"))+(n===1?" person":" people"));} 796 else if(c.staff_range)bits.push(esc(c.staff_range)+" people"); 797 if(c.founded)bits.push("since "+esc(String(c.founded))); 798 const where=[c.city,c.country].filter(Boolean).map(esc).join(", "); 799 if(where)bits.push(where); 800 if(c.offices>1)bits.push(esc(String(c.offices))+" offices"); 801 // A broken logo removes itself rather than leaving a gap where a company's face should be. 802 // Deliberately NOT lazy: this image is the proof we know who they are, and it is on screen the 803 // moment the card is. Lazy loading also never fires while the panel is still collapsing open. 804 const logo=c.logo?`<img class="cocard-logo" src="${esc(c.logo)}" alt="" onerror="this.remove()">`:""; 805 const chips=(label,list)=>(list&&list.length) 806 ?`<div class="cocard-row"><span class="cocard-k">${label}</span><span class="cocard-chips">`+ 807 list.map(v=>`<span class="chip">${esc(String(v))}</span>`).join("")+`</span></div>`:""; 808 // "What you do" is their own vocabulary and is the line that reads as us genuinely knowing them. 809 // 810 // Technologies are deliberately NOT shown to the visitor. The detector cannot tell a technology 811 // a company USES from one it merely names on its site, and that distinction collapses for 812 // exactly our ICP: agencies, consultancies and software-services firms list the stacks they 813 // staff. IGA Consulting â 28 people â came back "runs SAP", because SAP is a competence they 814 // sell. One wrong fact costs more than five right ones on a card whose whole job is to prove we 815 // know them. The data is still stored and still visible in the admin, where being wrong is cheap. 816 const rows=chips("What you do",c.keywords)+chips("Also in",c.industries); 817 const li=c.linkedin?`<a class="cocard-li" href="${esc(c.linkedin)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>`:""; 818 return `<div class="card"><h3>Your company</h3><div class="cocard">${logo}`+ 819 `<div class="cocard-b"><div class="cocard-name">${esc(c.name)}</div>`+ 820 (bits.length?`<div class="cocard-meta">${bits.join(" · ")}</div>`:"")+ 821 `</div></div>`+ 822 (rows?`<div class="cocard-rows">${rows}</div>`:"")+li+ 823 `</div>`; 824} 825function renderCompanyCard(c){ 826 const h=companyCardHtml(c); 827 if(!h||document.getElementById("coCard"))return; 828 // The verified card says who they are better than the research's facts; never show both. 829 const facts=document.getElementById("coFacts");if(facts)facts.remove(); 830 researchPanel.insertAdjacentHTML("afterbegin",`<div id="coCard">${h}</div>`); 831 scrollDown(); 832}
833function renderIcpBlock(p){ 834 const h=(document.getElementById("coCard")?"":`<div id="coFacts">${companyFactsHtml(p)}</div>`)+ 835 `<div id="icpStage">${icpHtml(p)}</div>`; 836 // Keep the company card on top: it is who they are, and it arrived first. 837 const co=document.getElementById("coCard"); 838 if(co){co.insertAdjacentHTML("afterend",h);scrollDown();return;} 839 researchPanel.insertAdjacentHTML("afterbegin",h); 840 scrollDown(); 841} 842 843// Return the visitor to the hero so they can genuinely try again: clear the one-shot guard, reset 844// the Turnstile widget (a token is single-use and expires, so the old one can never work twice), 845// and say what actually happened. 846function resetToHero(stage){ 847 // `autoRan` is deliberately NOT cleared. It gates the ?domain= / #domain= auto-run, and 848 // turnstile.reset() below fires the Turnstile callback, which calls maybeAutoRun(). Re-arming it 849 // here meant a deep-link arrival that got rejected would resubmit, get rejected again, and loop 850 // on /web/capture for as long as the tab stayed open. The auto-run is once per page load; after a 851 // rejection the visitor retries by hand with the button, which only needs researchStarted false. 852 researchStarted=false; 853 TS_TOKEN=""; 854 try{ if(window.turnstile)turnstile.reset(); }catch(e){} 855 main.classList.remove("engaged");document.body.classList.remove("engaged"); 856 newMsgBtn.classList.remove("show"); 857 introWrap.style.display="";introWrap.style.opacity="1"; 858 chatMsgs.innerHTML="";researchPanel.innerHTML='<div class="rp-hint">Researchingâ¦</div>'; 859 $("capErr").textContent = stage==="rate_limited" 860 ? "That's a lot of tries from one place. Give it a minute and go again." 861 : stage==="invalid_domain" 862 ? "That doesn't look like a website. Try it like yourcompany.com." 863 : "The bot check needs doing again, it expires after a few minutes. Tick it below and hit Show me."; 864 $("inDomain").focus(); 865} 866 867function renderReveal(d){ 868 const dos=d.dossier||{};state.cta=d.cta||{}; 869 track('research_shown',{size:dos.size_segment||''}); 870 const name=dos.detected_name||"your company"; 871 // The reveal replaces the whole panel, so the card has to be re-rendered here or the logo that 872 // has been on screen since the first second would vanish at the moment of the payoff. 873 // Three sibling cards: how Sellton would run it, who their customer is, the three companies. The 874 // first used to stay open around the other two, so "who your best customer is" read as a part of 875 // "how Sellton would run this" and sat nested two borders deep. 876 let h=(state.companyCard?companyCardHtml(state.companyCard):companyFactsHtml(dos))+ 877 `<div class="rp-hint">What Sellton sees</div> 878 <div class="rp-company">${esc(name)}</div> 879 <div class="rp-summary">${esc(dos.company_summary||"")}</div> 880 <div class="card"><h3>How Sellton would run this for you</h3><div style="font-size:15px;line-height:1.65;color:var(--text-dim)">${esc(dos.how_ARI_helps||"")}</div></div>`; 881 h+=icpHtml(dos); 882 const icps=dos.ideal_customers||[]; 883 if(icps.length){ 884 h+=`<div class="card"><h3>3 customers I'd go after first</h3><div class="prospects">`; 885 icps.forEach((c,i)=>{h+=`<div class="prospect" style="animation-delay:${i*0.1}s"><div class="pname">${esc(c.name||"")}</div><div class="pweb">${esc(c.website||"")}</div><div class="pmeta">${esc(c.estimated_size||"")}</div><div class="preason">${esc(c.reason||"")}</div></div>`;}); 886 h+=`</div></div>`; 887 } 888 const cta=d.cta||{}; 889 if(cta.url)h+=`<a class="btn cta" href="${esc(cta.url)}" target="_blank" rel="noopener" onclick="track('cta_click',{type:'${esc(cta.type||"")}'})">${esc(cta.label||"Get started")} â</a>`; 890 researchPanel.innerHTML=h; 891 addChatMsg("assistant","There's your preview: the 3 I'd open with, and how I'd win each. Sellton runs this for you continuously, on its own."); 892 // The chat opens here, with no form in front of it (web-flow-v2, 2026-09-25: four in five 893 // visitors used to leave at the name-and-email form, before we had said a word to them). The 894 // opener is the server's line, the founding-hundred verdict for a fit, and /web/chat replays the 895 // same line into its history, so what they read and what the chat knows cannot differ. The 896 // address is asked for later, in the chat, once the offer has landed. 897 // No autofocus on a phone: the keyboard would cover the research they just waited for. 898 setTimeout(()=>{ 899 if(d.opening_message)flagNew(addChatMsg("assistant",d.opening_message)); 900 enableChat(!IS_TOUCH); 901 },600); 902} 903 904const IS_TOUCH=matchMedia("(pointer:coarse)").matches; 905function enableChat(focus){composer.disabled=false;sendBtn.disabled=false;if(focus!==false)composer.focus();} 906// One message in flight at a time. The box itself stays enabled while we wait: disabling a focused 907// field closes the iPhone keyboard, and re-focusing it afterwards opened it again â the page jumped 908// twice on every message. 909let sending=false; 910async function sendChat(){ 911 const msg=composer.value.trim();if(!msg||composer.disabled||sending)return; 912 sending=true;sendBtn.disabled=true; 913 showMsg(addChatMsg("user",msg));composer.value="";composer.style.height="auto"; 914 const t=addChatTyping();showMsg(t); 915 try{ 916 // Until they give an address the hook and any calculator numbers ride along, so the lead the 917 // server makes from a typed address carries them, as the old form's lead did. 918 const payload={session_id:sid,domain:state.domain,email:state.email,message:msg,hook_id:hookId()}; 919 if(!state.email){const calc=readCalcHandoff();if(calc)payload.calculator=calc;} 920 const r=await fetch(API+"/web/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); 921 const d=await r.json();t.remove();const m=addChatMsg("assistant",d.reply||"â¦"); 922 // They typed their address and the server made them a lead: every later message carries it. 923 if(d.captured_email){state.email=d.captured_email;clearCalcHandoff();track('email_captured',{from:'chat'});} 924 // Server says this reply points at the next step: put the button in the bubble, not across the page. 925 if(d.show_cta&&state.cta&&state.cta.url){ 926 const a=document.createElement("a");a.className="btn cta inline";a.href=state.cta.url; 927 a.target="_blank";a.rel="noopener";a.textContent=(state.cta.label||"Start onboarding")+" â"; 928 a.onclick=()=>track('cta_click',{type:state.cta.type||"",from:"chat"}); 929 m.classList.add("has-cta");m.appendChild(a);chatScroll(); 930 } 931 // A long reply is read from its first line, a short one sits just above the box. 932 const room=window.innerHeight-(chatInputEl.offsetHeight||72); 933 showMsg(m,m.offsetHeight>room*0.7?"start":"end"); 934 }catch(e){t.remove();showMsg(addChatMsg("assistant","Lost that one, try again?"));} 935 // Never lock the visitor out, keep the chat open even past the soft cap. 936 sending=false;sendBtn.disabled=false; 937 if(!IS_TOUCH)composer.focus(); 938}
939sendBtn.onclick=sendChat; 940composer.addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();sendChat();}}); 941composer.addEventListener("input",()=>{composer.style.height="auto";composer.style.height=Math.min(composer.scrollHeight,120)+"px";}); 942 943// ---- consent-gated analytics (GA4 / Meta Pixel / LinkedIn) ---- 944let TRACK_LOADED=false; 945function track(event,params){try{ 946 if(window.gtag)gtag('event',event,params||{}); 947 if(window.fbq)fbq('trackCustom',event,params||{}); 948}catch(e){}} 949async function loadTracking(){ 950 if(TRACK_LOADED)return;TRACK_LOADED=true; 951 let cfg={};try{cfg=await (await fetch(API+"/web/config")).json();}catch(e){return;} 952 if(cfg.ga4_id){ 953 const s=document.createElement('script');s.async=true;s.src='https://www.googletagmanager.com/gtag/js?id='+cfg.ga4_id;document.head.appendChild(s); 954 window.dataLayer=window.dataLayer||[];window.gtag=function(){dataLayer.push(arguments);};gtag('js',new Date());gtag('config',cfg.ga4_id); 955 } 956 if(cfg.meta_pixel_id){ 957 !function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js'); 958 fbq('init',cfg.meta_pixel_id);fbq('track','PageView'); 959 } 960 if(cfg.linkedin_partner_id){ 961 window._linkedin_partner_id=cfg.linkedin_partner_id;window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];window._linkedin_data_partner_ids.push(cfg.linkedin_partner_id); 962 const l=document.createElement('script');l.async=true;l.src='https://snap.licdn.com/li.lms-analytics/insight.min.js';document.head.appendChild(l); 963 } 964 track('page_view'); 965} 966// While the bar is open the pinned message box sits on top of it, not under it (--consent-h). 967function syncConsentH(){ 968 const b=$("consentBar"); 969 document.documentElement.style.setProperty("--consent-h",b.style.display==="flex"?b.offsetHeight+"px":"0px"); 970} 971window.addEventListener("resize",syncConsentH); 972(function initConsent(){ 973 const c=localStorage.getItem("sellton_consent"); 974 if(c==="accepted")loadTracking(); 975 else if(c!=="declined"){$("consentBar").style.display="flex";document.body.classList.add("consent-open");} 976 syncConsentH(); 977})(); 978$("consentAccept").onclick=()=>{localStorage.setItem("sellton_consent","accepted");$("consentBar").style.display="none";document.body.classList.remove("consent-open");syncConsentH();loadTracking();}; 979$("consentDecline").onclick=()=>{localStorage.setItem("sellton_consent","declined");$("consentBar").style.display="none";document.body.classList.remove("consent-open");syncConsentH();}; 980 981 982// ---- Turnstile (bot challenge on submit) ---- 983async function initConfig(){ 984 try{ CFG=await (await fetch(API+"/web/config")).json(); }catch(e){ CFG={}; } 985 if(CFG && CFG.turnstile_site_key){ 986 window.onTSLoad=function(){ 987 // A Turnstile token expires after a few minutes and is single-use. Someone who reads the 988 // page before submitting would otherwise hit "please complete the check" for no visible 989 // reason, so refresh it in place: the visitor never sees the failure. 990 try{ turnstile.render('#cf-turnstile',{ 991 sitekey:CFG.turnstile_site_key, 992 callback:function(t){TS_TOKEN=t;maybeAutoRun();}, 993 'expired-callback':function(){TS_TOKEN="";try{turnstile.reset();}catch(e){}}, 994 'error-callback':function(){TS_TOKEN="";} 995 }); }catch(e){} 996 }; 997 const s=document.createElement('script'); 998 s.src='https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTSLoad'; 999 s.async=true;s.defer=true;document.head.appendChild(s); 1000 } 1001 maybeAutoRun(); // no-Turnstile case fires now; with Turnstile it's a no-op until the token callback 1002} 1003initConfig(); 1004 1005typeIntro(); 1006</script>
1006 1007</body> 1008</html>
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.