PageSourceSearch

https://sellton.ai/

html sellton.ai collected 2026-10-05 15:49:35 UTC 71,860 bytes, 1,008 lines download raw bytes

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=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[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 &#8599;</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(" &middot; ")}</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.