@pmeweb/plugin-chat 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALL.md CHANGED
@@ -149,8 +149,8 @@ package's, approved once.
149
149
  and photos paragraphs when those keys are set), only while `enabled` is true: it needs
150
150
  `@pmeweb/consent` 1.1.0 or later (manifest `activeWhen`; 1.0.0 would print it switched off, and
151
151
  check-plugins refuses that).
152
- 10. **Not in `pmeweb.json` `forms`**: the chat's Turnstile widget renders only once the panel is
153
- open, so the portal's form-health browser check would find nothing on a page load.
152
+ 10. **Not in `pmeweb.json` `forms`**: the chat's Turnstile widget renders only after the visitor
153
+ interacts with the page or the opener appears (20 s), so the portal's form-health browser check would find nothing on a page load.
154
154
  11. `npm run build && node ../../a11y-tools/check-plugins.mjs .` until PASS; `npm run check`.
155
155
  12. **Portal** (« Modules › Assistant virtuel »): PmeWeb sets `alertEmails`
156
156
  (`alertEmails` is needed before the switch goes on: pmeweb-chat refuses a site without it), `alertPhone` if texts are on, and
package/README.md CHANGED
@@ -52,8 +52,39 @@ file ».
52
52
  face (`avatar`: photos, illustration or a « Sans visage » icon), the icon's colour (`avatarBg`,
53
53
  default the site's accent) and the name (`botName`, which ships with « Publier »). `logo` stays
54
54
  PmeWeb's, set at install.
55
- - **Nothing loads with the page.** The bubble is HTML and CSS; the panel, Turnstile and the socket
56
- arrive when the visitor presses, hovers, focuses or types on it.
55
+ - **Nothing loads with the page.** The bubble is HTML and CSS (the inline trigger, under the
56
+ manifest's `inlineBudget`). The panel's code (`widget.js`) arrives on the visitor's first sign of
57
+ life anywhere on the page (a scroll, a pointer moving, a press, a key or a focus) or when the
58
+ opener appears (20 s), whichever comes first. The socket opens only on the first message.
59
+ - **Turnstile starts before the panel opens, never in a test** (operator, 2026-11-13). Loaded when
60
+ the panel opened, the challenge had no time to observe the visitor and often showed its
61
+ « Vérifiez que vous êtes humain » box. It now starts once `widget.js` is in AND the page has
62
+ painted (`load`, first contentful paint, LCP, whichever is latest) plus `WARM_MS` (3 s), or at
63
+ once on a press or a focus in the chat, or when the panel opens, whichever comes first. A loaded
64
+ `api.js` racing the first paint costs up to 30 PageSpeed points (`@pmeweb/turnstile`'s README);
65
+ Lighthouse and PageSpeed never scroll, move a pointer or type, so they fetch neither `widget.js`
66
+ nor `api.js` before the opener's 20 s. Measured on a demo page with the chat on (1.3.1,
67
+ Lighthouse 12.6.1 mobile, 3 runs): no request to `challenges.cloudflare.com` or `widget.js`,
68
+ performance 100. In Chromium at 390 px: a visitor scrolling at 1 s gets `widget.js` at 1.0 s,
69
+ `api.js` at 4.0 s; one who never touches the page, `widget.js` with the opener at 20.0 s,
70
+ `api.js` at 23.0 s; in both the token is minted before the panel opens.
71
+ - **The Cloudflare box shows only when Cloudflare asks.** The widgets (`interaction-only`) render
72
+ hidden in the closed panel and mint their token there. Each sits in its own row, collapsed until
73
+ Turnstile's `before-interactive` or `error` callback opens it; its success callback collapses it
74
+ again (« Succès ! » goes away, 0.25 s, at once under reduced motion), and a later expiry that
75
+ needs a click opens it again. In the composer the row sits above the field, so the field never
76
+ moves. An unused token is refreshed by Turnstile before its 300 s expiry; after each post the
77
+ widget is reset (`turnstile.reset(widget)`, never while its row is asking). A widget added after
78
+ another form already loaded `api.js` is rendered by the chat itself (implicit rendering scans the
79
+ page once).
80
+ - **The first message reads like a reply.** The typing dots show at once while the conversation
81
+ starts (token, `/api/chat/start`, socket); « Un instant… » joins them only past 8 s. Nothing is
82
+ started before the visitor writes: pmeweb-chat counts every start toward the visitor's
83
+ conversations of the day, so only the token is prepared ahead.
84
+ - **The thread stays at the bottom** while the visitor is there (within 40 px): streamed text, the
85
+ final message, the dots, a photo once decoded, the takeover and hand-back lines, the end block, the
86
+ composer growing. One smooth scroll per frame (instant under reduced motion). A visitor who
87
+ scrolled up to read stays put; sending a message always scrolls down.
57
88
  - **Openers.** One line per page, from the site's dictionary (`chat.openers`, `chat.defaultOpener`). At most 50 characters (two lines on a phone): a longer one fails the build, naming it.
58
89
  Shown once per visit (20 s, 30 % of the page, or exit intent with a mouse), never while the panel
59
90
  is open, never while the footer is in view (it waits for the next of those triggers). On a phone
@@ -70,7 +101,9 @@ file ».
70
101
  (the first one wins over the footer). Never while the panel is open or while focus is in the
71
102
  bubble or the opener (a focusout re-checks). Reduced motion: hidden and shown at once.
72
103
  - **Never passing for a person.** « Assistant virtuel » is always in text; the green dot appears
73
- only when the owner has taken over, with the owner's name.
104
+ only when the owner has taken over, with the owner's name. Where the owner takes over, the thread
105
+ shows « {nom} a pris le relais »; where they hand back (« Rendre à l’assistant »), « L’assistant
106
+ virtuel a repris la conversation » (the dot muted), live and on replay, once per hand-back.
74
107
  - **The socket.** A drop (1006, a forced deploy of pmeweb-chat) reconnects with backoff 1 to 30 s
75
108
  and catches up from the last message seen; a message typed meanwhile is queued and sent on open,
76
109
  and after 3 sends without an echo shows « Non envoyé, Réessayer ». A 44xx close never reconnects.
@@ -1 +1 @@
1
- (()=>{function S({doc:r=document,hostname:n=globalThis.location?.hostname??""}={}){let a=r.getElementById("pmw-hosts");if(!a)return!1;let s;try{s=JSON.parse(a.textContent??"")}catch{return!1}if(!Array.isArray(s))return!1;let c=String(n).toLowerCase();return s.some(e=>typeof e=="string"&&e.toLowerCase()===c)}var A=["pointerdown","pointerenter","focusin","keydown"];function T(r,n,{events:a=A}={}){let s=!1,c=()=>{if(!s){s=!0;for(let e of a)r.removeEventListener(e,c);n()}};for(let e of a)r.addEventListener(e,c,{passive:!0});return{run:c}}var w="pmw-chat-opener",O=2e4,I=.3;function E({doc:r=document,win:n=window,hostname:a=n.location.hostname,importLazy:s=e=>import(e),onInteraction:c=T}={}){let e=r.getElementById("pmw-chat");if(!e)return null;e.hidden=!1;let l=e.querySelector(".pmw-chat__teaser"),u=null,d=()=>u??(u=s(e.dataset.pmwLazy).then(t=>t.mountChat(e),t=>{throw u=null,t}));c(e,()=>d().catch(()=>{}));let m=S({doc:r,hostname:a});if(!m){let t=()=>{let o=new URLSearchParams(n.location.hash.slice(1)).get("pmw-chat");o==="panel"&&!e.hasAttribute("data-open")?d().then(i=>i.open(),()=>{}):o==="bubble"&&u&&u.then(i=>i.close(),()=>{})};t(),n.addEventListener("hashchange",t)}let h=!1,v=(t=r.activeElement)=>{let o=h&&!e.hasAttribute("data-open")&&!e.contains(t);e.toggleAttribute("inert",o)},y=r.querySelector("[data-pmw-chat-avoid]")??[...r.querySelectorAll("footer")].pop();m&&y&&(new n.IntersectionObserver(t=>{h=t.at(-1).isIntersecting,v()}).observe(y),e.addEventListener("focusout",t=>v(t.relatedTarget)));let g=n.sessionStorage,f=!1;try{f=!!g?.getItem(w)}catch{}let b=(t,o,i)=>t.addEventListener(o,x=>f||i(x),{passive:!0}),L=()=>{f=!0;try{g?.setItem(w,"1")}catch{}},p=()=>{f||h||!l||e.hasAttribute("data-open")||(L(),l.hidden=!1)};return e.addEventListener("click",t=>{let o=t.target.closest?.("[data-pmw-chat-open],[data-pmw-chat-dismiss]");o&&(l&&(l.hidden=!0),L(),o.hasAttribute("data-pmw-chat-open")&&d().then(i=>i.open(),()=>{}))}),!f&&l&&m&&(n.setTimeout(p,O),b(n,"scroll",()=>{let t=r.documentElement.scrollHeight-n.innerHeight;t>0&&n.scrollY/t>=I&&p()}),n.matchMedia?.("(pointer: fine)").matches&&b(r,"mouseout",t=>{!t.relatedTarget&&t.clientY<=0&&p()})),{load:d,show:p}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>E()):E();})();
1
+ (()=>{function x({doc:o=document,hostname:n=globalThis.location?.hostname??""}={}){let a=o.getElementById("pmw-hosts");if(!a)return!1;let s;try{s=JSON.parse(a.textContent??"")}catch{return!1}if(!Array.isArray(s))return!1;let c=String(n).toLowerCase();return s.some(t=>typeof t=="string"&&t.toLowerCase()===c)}var v=["pointerdown","pointerenter","focusin","keydown"];function A(o,n,{events:a=v}={}){let s=!1,c=()=>{if(!s){s=!0;for(let t of a)o.removeEventListener(t,c);n()}};for(let t of a)o.addEventListener(t,c,{passive:!0});return{run:c}}var I="pmw-chat-opener",C=2e4,N=.3,P=[...v,"pointermove","scroll"];function y({doc:o=document,win:n=window,hostname:a=n.location.hostname,importLazy:s=t=>import(t),onInteraction:c=A}={}){let t=o.getElementById("pmw-chat");if(!t)return null;t.hidden=!1;let i=t.querySelector(".pmw-chat__teaser"),f=null,p=()=>f??(f=s(t.dataset.pmwLazy).then(e=>e.mountChat(t),e=>{throw f=null,e})),g=()=>p().catch(()=>{}),L=()=>p().then(e=>e.open(),()=>{}),d=()=>t.hasAttribute("data-open");c(o,g,{events:P});let m=x({doc:o,hostname:a});if(!m){let e=()=>{let r=new URLSearchParams(n.location.hash.slice(1)).get("pmw-chat");r==="panel"&&!d()?L():r==="bubble"&&f&&f.then(E=>E.close?.(),()=>{})};e(),n.addEventListener("hashchange",e)}let h=!1,b=(e=o.activeElement)=>{let r=h&&!d()&&!t.contains(e);t.toggleAttribute("inert",r)},T=o.querySelector("[data-pmw-chat-avoid]")??[...o.querySelectorAll("footer")].pop();m&&T&&(new n.IntersectionObserver(e=>{h=e.at(-1).isIntersecting,b()}).observe(T),t.addEventListener("focusout",e=>b(e.relatedTarget)));let S=n.sessionStorage,l=!1;try{l=!!S?.getItem(I)}catch{}let O=(e,r,E)=>e.addEventListener(r,_=>l||E(_),{passive:!0}),w=()=>{l=!0;try{S?.setItem(I,"1")}catch{}},u=()=>{l||h||!i||d()||(w(),i.hidden=!1,g())};return t.addEventListener("click",e=>{let r=e.target.closest?.("[data-pmw-chat-open],[data-pmw-chat-dismiss]");r&&(i&&(i.hidden=!0),w(),r.hasAttribute("data-pmw-chat-open")&&L())}),!l&&i&&m&&(n.setTimeout(u,C),O(n,"scroll",()=>{let e=o.documentElement.scrollHeight-n.innerHeight;e>0&&n.scrollY/e>=N&&u()}),n.matchMedia?.("(pointer: fine)").matches&&O(o,"mouseout",e=>{!e.relatedTarget&&e.clientY<=0&&u()})),{load:p,show:u}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>y()):y();})();
package/dist/widget.js CHANGED
@@ -1,2 +1,2 @@
1
- var Je=`.pmw-chat__panel{position:fixed;z-index:81;inset:0;display:flex;flex-direction:column;background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-fg, #1f2328);font-family:var(--pmw-chat-font, inherit);font-size:16px;line-height:1.45}.pmw-chat__panel[hidden],.pmw-chat__panel [hidden]{display:none!important}@media(min-width:640px){.pmw-chat__panel{inset:auto;right:calc(env(safe-area-inset-right,0px) + var(--pmw-chat-right, 16px));bottom:calc(env(safe-area-inset-bottom,0px) + var(--pmw-chat-bottom, 16px));width:380px;height:min(600px,calc(100dvh - 16px - env(safe-area-inset-bottom,0px) - var(--pmw-chat-bottom, 16px)));border:1px solid var(--pmw-chat-line, #e6e9f0);border-radius:20px;overflow:hidden;box-shadow:0 18px 48px #1018282e}}.pmw-chat__panel :where(button){font:inherit;cursor:pointer}.pmw-chat__panel :focus-visible{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__head{display:flex;align-items:center;gap:12px;padding:14px 10px 14px 16px;border-bottom:1px solid var(--pmw-chat-line, #e6e9f0)}@media(max-width:639.98px){.pmw-chat__head{padding:12px 8px 12px 16px}}.pmw-chat__face{position:relative;flex:none;display:flex}.pmw-chat__head .pmw-chat__avatar{width:44px;height:44px;box-shadow:none}.pmw-chat__head .pmw-chat__avatar--icon{box-shadow:0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent)}.pmw-chat__head .pmw-chat__avatar img{width:44px;height:44px}.pmw-chat__head .pmw-chat__avatar--logo img{width:32px;height:32px}.pmw-chat__head .pmw-chat__avatar svg{width:100%;height:100%}.pmw-chat__dot{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37);box-shadow:0 0 0 2px var(--pmw-chat-panel-bg, #ffffff)}.pmw-chat__who{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.pmw-chat__name{margin:0;font-weight:600;font-size:16px}.pmw-chat__role,.pmw-chat__notice{margin:0;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__panel[data-mode=human] .pmw-chat__role{color:var(--pmw-chat-live, #1a7f37);font-weight:500}.pmw-chat__notice a{color:var(--pmw-chat-link, #0a1b3d);text-decoration:underline;text-underline-offset:2px}.pmw-chat__close{flex:none;width:44px;height:44px;padding:0;border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;background:transparent;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__close:hover{background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__close svg{width:18px;height:18px}.pmw-chat__body{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding:18px 16px;background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}.pmw-chat__log>li{margin-top:4px}.pmw-chat__log>li:first-child,.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--bot+.pmw-chat__typing,.pmw-chat__msg--owner+.pmw-chat__msg--owner,.pmw-chat__msg--owner+.pmw-chat__typing,.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{margin-top:0}.pmw-chat__msg{max-width:82%;padding:10px 14px;border-radius:18px;white-space:pre-wrap;overflow-wrap:anywhere}.pmw-chat__msg--bot,.pmw-chat__msg--owner{align-self:flex-start;border-bottom-left-radius:6px;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__msg--bot{background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__msg--owner{background:var(--pmw-chat-owner-bg, #eef2fa);border:1px solid var(--pmw-chat-owner-border, #d5ddef)}.pmw-chat__msg--visitor{align-self:flex-end;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff);border-bottom-right-radius:6px}.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--owner+.pmw-chat__msg--owner{border-top-left-radius:6px}.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{border-top-right-radius:6px}.pmw-chat__msg:has(>.pmw-chat__photo){padding:4px}.pmw-chat__msg:has(>.pmw-chat__photo) .pmw-chat__text:empty{display:none}.pmw-chat__msg.is-pending{opacity:.75}.pmw-chat__msg.is-failed{outline:2px solid var(--pmw-chat-error, #b42318);outline-offset:2px}.pmw-chat__retry{display:block;margin-top:4px;font-size:14px}.pmw-chat__retry button{padding:0;border:0;background:none;color:inherit;text-decoration:underline}.pmw-chat__typing{align-self:flex-start;display:flex;gap:5px;padding:14px 16px;border-radius:18px 18px 18px 6px;background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__typing span{width:7px;height:7px;border-radius:50%;background:var(--pmw-chat-muted, #4b5563);animation:pmw-chat-dot 1.2s infinite ease-in-out}.pmw-chat__typing span:nth-child(2){animation-delay:.15s}.pmw-chat__typing span:nth-child(3){animation-delay:.3s}@keyframes pmw-chat-dot{0%,80%,to{opacity:.3}40%{opacity:1}}@media(prefers-reduced-motion:reduce){.pmw-chat__typing span{animation:none;opacity:.7}}.pmw-chat__log>.pmw-chat__relay{align-self:stretch;margin:18px 0 8px;display:flex;align-items:center;gap:10px;font-size:13px;font-weight:500;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__relay:before,.pmw-chat__relay:after{content:"";flex:1;min-width:16px;height:1px;background:var(--pmw-chat-rule, #cfd5e1)}.pmw-chat__relay-label{display:flex;align-items:center;gap:6px;text-align:center}.pmw-chat__relay-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37)}.pmw-chat__log>.pmw-chat__relay+li{margin-top:0}.pmw-chat__state{margin:8px 0 0;font-size:14px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__state:empty{display:none}.pmw-chat__ended{margin-top:22px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center}.pmw-chat__ended:before{content:"";align-self:stretch;height:1px;background:var(--pmw-chat-line, #e6e9f0)}.pmw-chat__ended p{margin:0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__restart{min-height:44px;padding:0 20px;border-radius:22px;border:1px solid var(--pmw-chat-link, #0a1b3d);background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-link, #0a1b3d);font-weight:600}.pmw-chat__composer{display:flex;flex-direction:column;gap:6px;padding:12px 12px 10px;border-top:1px solid var(--pmw-chat-line, #e6e9f0);background:var(--pmw-chat-panel-bg, #ffffff)}@media(max-width:639.98px){.pmw-chat__composer{padding:10px 12px calc(14px + env(safe-area-inset-bottom,0px))}}.pmw-chat__box{display:flex;align-items:flex-end;gap:6px;padding:4px;border-radius:24px;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__box textarea{flex:1;min-width:0;box-sizing:border-box;min-height:44px;max-height:132px;resize:none;margin:0;padding:10px 2px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:16px;line-height:1.5}.pmw-chat__box textarea::placeholder{color:#6b7280;opacity:1}@supports selector(:has(*)){.pmw-chat__box textarea:focus-visible{outline:none}.pmw-chat__box:has(textarea:focus-visible){outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}}.pmw-chat__send{flex:none;min-height:44px;padding:0 18px;border:0;border-radius:22px;font-weight:600;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__send:disabled,.pmw-chat__box textarea:disabled{cursor:not-allowed;opacity:.6}.pmw-chat__composer .pmw-turnstile,.pmw-chat__fallback .pmw-turnstile{align-self:stretch}.pmw-chat__attach{flex:none;position:relative;width:44px;height:44px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:inherit}.pmw-chat__attach:hover{background:#eef0f4}.pmw-chat__attach svg{width:22px;height:22px}.pmw-chat__attach:focus-within{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__hint{margin:0 8px;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__composer .pmw-chat__photo-error{margin:0 8px}.pmw-chat__photo{display:block;max-width:100%;max-height:220px;border-radius:14px}.pmw-chat__fallback{display:flex;flex-direction:column;gap:14px;margin-top:16px}.pmw-chat__fb-title{margin:0;font-size:20px;font-weight:600;line-height:1.3}.pmw-chat__fb-intro{margin:-8px 0 0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__field{display:flex;flex-direction:column;gap:4px}.pmw-chat__field label{font-size:14px;font-weight:600}.pmw-chat__field input,.pmw-chat__field textarea{font:inherit;font-size:16px;padding:10px 14px;border-radius:12px;resize:none;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__fallback .pmw-chat__send{align-self:stretch;min-height:48px;border-radius:24px}.pmw-chat__error{margin:0;font-size:14px;color:var(--pmw-chat-error, #b42318)}.pmw-chat__error:empty{display:none}.pmw-chat__sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.pmw-chat[data-unread] .pmw-chat__bubble{position:relative}.pmw-chat[data-unread] .pmw-chat__bubble:after{content:"";position:absolute;top:2px;left:38px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-error, #b42318);box-shadow:0 0 0 2px #fff}
2
- `;var je=new Set;function Ge(s,{doc:l=document,attrs:r={}}={}){if(je.has(s)||(je.add(s),l.querySelector(`script[src="${s}"]`)))return;let t=l.createElement("script");t.src=s,t.async=!0;for(let[d,m]of Object.entries(r))t.setAttribute(d,m);l.head.append(t)}function Ve({doc:s=document,hostname:l=globalThis.location?.hostname??""}={}){let r=s.getElementById("pmw-hosts");if(!r)return!1;let t;try{t=JSON.parse(r.textContent??"")}catch{return!1}if(!Array.isArray(t))return!1;let d=String(l).toLowerCase();return t.some(m=>typeof m=="string"&&m.toLowerCase()===d)}var mt="https://challenges.cloudflare.com/turnstile/v0/api.js";var ut=15e3,Ke=200;function fe(s=document){Ge(mt,{doc:s})}var ft=s=>s.querySelector('input[name="cf-turnstile-response"]')?.value??"",wt=s=>{let l=s.querySelector(".cf-turnstile");return!!l&&l.offsetHeight>0};async function Xe(s,{win:l=window,timeoutMs:r=ut}={}){typeof s.pmwTurnstileLoad=="function"?s.pmwTurnstileLoad():fe(l.document);let t=0;for(;ft(s)==="";){if(!wt(s)){if(t>=r)return!1;t+=Ke}await new Promise(d=>l.setTimeout(d,Ke))}return!0}function Ye(s,{win:l=window}={}){let r=s.querySelector(".cf-turnstile");if(!r||r.offsetHeight>0)return!1;try{return l.turnstile?.reset(r),!0}catch{return!1}}var Qe={unauthorized:4401,expired:4410,rpc:4426,deleted:4404};var _t={fr:{assistant:"Assistant virtuel",openLabel:"Assistant virtuel, ouvrir le clavardage",dismissTeaser:"Masquer ce message",close:"Fermer le clavardage",panelTitle:"Clavardage",automated:"R\xE9ponses automatiques, tir\xE9es du contenu de ce site.",privacy:"Confidentialit\xE9",you:"Vous",messageLabel:"Votre message",send:"Envoyer",live:"Vous r\xE9pond en direct",relay:"{name} a pris le relais",relayGeneric:"Une personne de l\u2019\xE9quipe a pris le relais",connecting:"Connexion\u2026",reconnecting:"Connexion perdue, reconnexion\u2026",notSent:"Non envoy\xE9.",retry:"R\xE9essayer",ended:"Cette conversation est termin\xE9e.",newConversation:"Nouvelle conversation",reload:"Rechargez la page pour continuer.",unavailable:"Un probl\xE8me est survenu. R\xE9essayez dans un moment.",turnstile:"L\u2019envoi n\u2019a pas abouti. R\xE9essayez.",tooMany:"Un instant, puis r\xE9essayez.",fallbackTitle:"Laissez-nous vos coordonn\xE9es",fallbackCap:"Laissez vos coordonn\xE9es et nous vous recontacterons.",fallbackDown:"Laissez vos coordonn\xE9es et nous vous recontacterons.",name:"Votre nom",contact:"T\xE9l\xE9phone ou courriel",need:"Votre demande",fallbackSend:"Envoyer la demande",fallbackSent:"Merci, votre demande a \xE9t\xE9 transmise.",fallbackMissing:"Remplissez les trois champs.",said:"{name}\xA0: {text}",attachPhoto:"Joindre une photo",photoHint:"\xC9vitez les photos de personnes ou de pi\xE8ces d\u2019identit\xE9.",photoAlt:"Votre photo",photoSent:"Photo envoy\xE9e",photoLimit:"Trois photos au plus par conversation.",photoTooLarge:"Cette photo est trop lourde.",photoUnreadable:"Ce fichier n\u2019est pas une photo lisible.",photoFailed:"La photo n\u2019a pas pu \xEAtre envoy\xE9e. R\xE9essayez.",demoAsk:"Pouvez-vous m\u2019en dire plus sur {topic}?",demoQuestion:"Quels sont vos d\xE9lais?",demoAnswer:"(D\xE9monstration) Ici, l\u2019assistant r\xE9pond \xE0 partir du contenu de votre site.",demoHint:"Aper\xE7u\xA0: la conversation est d\xE9sactiv\xE9e."},en:{assistant:"Virtual assistant",openLabel:"Virtual assistant, open the chat",dismissTeaser:"Hide this message",close:"Close the chat",panelTitle:"Chat",automated:"Automatic answers, drawn from this site\u2019s content.",privacy:"Privacy",you:"You",messageLabel:"Your message",send:"Send",live:"Replying live",relay:"{name} took over",relayGeneric:"A team member took over",connecting:"Connecting\u2026",reconnecting:"Connection lost, reconnecting\u2026",notSent:"Not sent.",retry:"Retry",ended:"This conversation has ended.",newConversation:"New conversation",reload:"Reload the page to continue.",unavailable:"Something went wrong. Try again in a moment.",turnstile:"Sending did not go through. Try again.",tooMany:"One moment, then try again.",fallbackTitle:"Leave us your details",fallbackCap:"Leave your details and we will get back to you.",fallbackDown:"Leave your details and we will get back to you.",name:"Your name",contact:"Phone or email",need:"Your request",fallbackSend:"Send the request",fallbackSent:"Thank you, your request was sent.",fallbackMissing:"Fill in all three fields.",said:"{name}: {text}",attachPhoto:"Attach a photo",photoHint:"Avoid photos of people or of identity documents.",photoAlt:"Your photo",photoSent:"Photo sent",photoLimit:"Three photos at most per conversation.",photoTooLarge:"This photo is too large.",photoUnreadable:"This file is not a readable photo.",photoFailed:"The photo could not be sent. Try again.",demoAsk:"Can you tell me more about {topic}?",demoQuestion:"What are your lead times?",demoAnswer:"(Demo) Here, the assistant answers from your site\u2019s content.",demoHint:"Preview: the conversation is turned off."}},Z=s=>String(s??"").slice(0,2).toLowerCase()==="en"?"en":"fr";function we(s,l={}){let r=_t[Z(s)],t={...r};for(let[d,m]of Object.entries(l??{}))typeof m=="string"&&d in r&&(t[d]=m);return t}var H=(s,l)=>s.replace(/\{(\w+)\}/g,(r,t)=>t in l?String(l[t]):r);var _e="pmw-chat",Ze=[1e3,2e3,4e3,8e3,16e3,3e4],et=3,gt="chat-lead",xt=3,bt=1600,vt=.85,yt=3e3,kt=8e3,St='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/></svg>';async function Tt(s,l){let r=await s.createImageBitmap(l,{imageOrientation:"from-image"}),t=Math.min(1,bt/Math.max(r.width,r.height)),d=Math.max(1,Math.round(r.width*t)),m=Math.max(1,Math.round(r.height*t)),u=s.document.createElement("canvas");return u.width=d,u.height=m,u.getContext("2d").drawImage(r,0,0,d,m),r.close?.(),new Promise((U,ee)=>u.toBlob(D=>D?U(D):ee(new Error("encode")),"image/jpeg",vt))}var Lt={fr:{nos:"vos",notre:"votre",nous:"vous"},en:{our:"your",us:"you",we:"you"}},Ct={fr:/^une question sur (.+?)\s*\?$/i,en:/^(?:a |any )?questions? (?:about|on) (.+?)\s*\?$/i};function Et(s,l){let r=Z(l),t=we(r),d=String(s??"").trim().match(Ct[r]);if(!d)return t.demoQuestion;let m=d[1].replace(/[\p{L}’']+/gu,u=>Lt[r][u.toLowerCase()]??u);return H(t.demoAsk,{topic:m})}var Ot='<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>';function o(s,l,r={},t){let d=s.createElement(l);for(let[m,u]of Object.entries(r))u!=null&&u!==!1&&d.setAttribute(m,u===!0?"":u);return t!==void 0&&(d.textContent=t),d}var At=s=>({load:()=>fe(s.document),async token(l){return await Xe(l,{win:s}),l.querySelector('input[name="cf-turnstile-response"]')?.value??""},reset:l=>Ye(l,{win:s})});function Jt(s,l={}){if(s.pmwChat)return s.pmwChat;let r=l.win??window,t=r.document,d=l.WebSocket??r.WebSocket,m=l.fetch??((...e)=>r.fetch(...e)),u=l.turnstile??At(r),U=l.uuid??(()=>r.crypto.randomUUID()),ee=l.shrink??(e=>Tt(r,e)),D=l.preview??(e=>new Promise((n,a)=>{let c=new r.FileReader;c.onload=()=>n(String(c.result)),c.onerror=()=>a(c.error),c.readAsDataURL(e)})),S={};try{S=JSON.parse(s.querySelector("[data-pmw-chat-config]")?.textContent??"{}")}catch{}let B=Z(S.lang),i=we(B,S.strings),ge=s.dataset.pmwChatOpener??"",tt=s.querySelector(".pmw-chat__bubble"),f=l.demo??(S.demo===!0||!Ve({doc:t,hostname:r.location.hostname}));t.getElementById("pmw-chat-style")||t.head.append(o(t,"style",{id:"pmw-chat-style"},Je));let k=o(t,"div",{class:"pmw-chat__panel",role:"dialog","aria-modal":"true","aria-labelledby":"pmw-chat-name",hidden:!0}),xe=o(t,"div",{class:"pmw-chat__head"}),te=s.querySelector(".pmw-chat__avatar")?.cloneNode(!0)??o(t,"span",{class:"pmw-chat__avatar","aria-hidden":"true"});for(let e of te.querySelectorAll("[id]")){let n=e.id;e.id=`${n}-head`;for(let a of te.querySelectorAll(`[mask="url(#${n})"]`))a.setAttribute("mask",`url(#${n}-head)`)}let be=o(t,"div",{class:"pmw-chat__who"}),ve=o(t,"p",{class:"pmw-chat__name",id:"pmw-chat-name"}),$=o(t,"p",{class:"pmw-chat__role"}),ye=o(t,"span",{class:"pmw-chat__face"}),ke=o(t,"span",{class:"pmw-chat__dot","aria-hidden":"true",hidden:!0}),F=o(t,"button",{type:"button",class:"pmw-chat__close","aria-label":i.close});F.innerHTML=Ot;let J=o(t,"p",{class:"pmw-chat__notice"},i.automated);S.privacyHref&&(J.append(" "),J.append(o(t,"a",{href:S.privacyHref},i.privacy))),ye.append(te,ke),be.append(ve,$,J),xe.append(ye,be,F);let w=o(t,"ol",{class:"pmw-chat__log"}),at=o(t,"li",{class:"pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting"},ge),y=o(t,"li",{class:"pmw-chat__typing","aria-hidden":"true",hidden:!0});y.append(o(t,"span"),o(t,"span"),o(t,"span")),w.append(at);let ae=o(t,"p",{class:"pmw-chat__state",role:"status"}),Se=o(t,"p",{class:"pmw-chat__sr","aria-live":"polite"}),Te=e=>o(t,"div",{class:"cf-turnstile pmw-turnstile","data-sitekey":S.siteKey??"","data-action":"chat","data-theme":"light","data-language":B,"data-appearance":"interaction-only","data-size":"flexible"}),_=o(t,"form",{class:"pmw-chat__composer",novalidate:!0}),g=o(t,"textarea",{id:"pmw-chat-input",name:"message",rows:"1",maxlength:String(2e3),placeholder:i.messageLabel,autocomplete:"off",disabled:f,"aria-describedby":f?"pmw-chat-demo-hint":null}),nt=o(t,"button",{type:"submit",class:"pmw-chat__send",disabled:f},i.send),ne=o(t,"div",{class:"pmw-chat__box"});ne.append(o(t,"label",{class:"pmw-chat__sr",for:"pmw-chat-input"},i.messageLabel),g,nt),_.append(ne),f?_.append(o(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-demo-hint"},i.demoHint)):_.append(Te(_));let q=null,O=o(t,"p",{class:"pmw-chat__error pmw-chat__photo-error",role:"alert"});if(S.photos===!0){let e=o(t,"label",{class:"pmw-chat__attach"});q=o(t,"input",{type:"file",class:"pmw-chat__sr",accept:"image/*",multiple:!0,"aria-describedby":"pmw-chat-photo-hint",disabled:f}),e.innerHTML=St,e.prepend(q),e.append(o(t,"span",{class:"pmw-chat__sr"},i.attachPhoto)),ne.insertBefore(e,g),_.append(o(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-photo-hint"},i.photoHint),O)}let j=o(t,"div",{class:"pmw-chat__ended",hidden:!0}),Le=o(t,"p",{},i.ended),G=o(t,"button",{type:"button",class:"pmw-chat__restart"},i.newConversation);j.append(Le,G);let x=o(t,"form",{class:"pmw-chat__fallback",novalidate:!0,hidden:!0,"aria-labelledby":"pmw-chat-fb-title"}),Ce=o(t,"p",{class:"pmw-chat__fb-intro"}),oe=(e,n,a)=>{let c=`pmw-chat-fb-${e}`,p=o(t,"div",{class:"pmw-chat__field"});return p.append(o(t,"label",{for:c},i[e]),o(t,n,{id:c,name:e,required:!0,...a})),p},V=o(t,"p",{class:"pmw-chat__error",role:"alert"});x.append(o(t,"p",{class:"pmw-chat__fb-title",id:"pmw-chat-fb-title"},i.fallbackTitle),Ce,oe("name","input",{autocomplete:"name",maxlength:"100"}),oe("contact","input",{autocomplete:"on",maxlength:"200"}),oe("need","textarea",{rows:"3",maxlength:"2000"}),V,o(t,"button",{type:"submit",class:"pmw-chat__send"},i.fallbackSend)),f||x.append(Te(x));let Ee=o(t,"div",{class:"pmw-chat__body"});Ee.append(w,ae,j,x),k.append(xe,Ee,_,Se),s.append(k);let h=null;if(!f)try{h=JSON.parse(r.sessionStorage.getItem(_e)??"null")}catch{h=null}let Oe=()=>{try{r.sessionStorage.setItem(_e,JSON.stringify(h))}catch{}},b=null,re=0,se=null,R=null,K=0,I="bot",T=null,A=!1,Ae=0,ie=null,ce=new Set,L=new Map,X=new Set,le=0,Ne=()=>new URLSearchParams(r.location.hash.slice(1)).get("pmw-chat-botName")?.trim()||null,Me=f?Ne():null,pe=()=>f?Me:h?.botName||null;function z(){let e=I==="human";ve.textContent=e?T||i.assistant:pe()||i.assistant,$.textContent=e?i.live:pe()?i.assistant:"",$.hidden=$.textContent==="",J.hidden=e,ke.hidden=!e,k.dataset.mode=I}z();let C=e=>{ae.textContent=e??"",ae.hidden=!e};C("");let Pe=e=>e.role==="visitor"?i.you:e.role==="owner"?e.by||T||i.assistant:pe()||i.assistant;function N(e,n){let a=o(t,"li",{class:`pmw-chat__msg pmw-chat__msg--${n}`,"data-id":e}),c=o(t,"span",{class:"pmw-chat__sr"}),p=o(t,"span",{class:"pmw-chat__text"});return a.append(c,p),w.insertBefore(a,y.parentNode===w?y:null),a}function qe(e,n){let a=e?e.previousElementSibling:y.parentNode===w?y.previousElementSibling:w.lastElementChild;for(;a;a=a.previousElementSibling){if(a.classList.contains("pmw-chat__relay")||a.classList.contains("pmw-chat__msg--owner"))return;if(a.classList.contains("pmw-chat__msg--bot"))break}let c=n?H(i.relay,{name:n}):i.relayGeneric,p=o(t,"li",{class:"pmw-chat__relay",role:"separator","aria-label":c}),v=o(t,"span",{class:"pmw-chat__relay-label","aria-hidden":"true"});v.append(o(t,"span",{class:"pmw-chat__relay-dot"}),c),p.append(v),w.insertBefore(p,e??(y.parentNode===w?y:null))}let Re=e=>[...w.querySelectorAll("li[data-id]")].find(n=>n.dataset.id===e),E=(e,n,a)=>{e.firstChild.textContent=H(i.said,{name:Pe(n),text:""}),e.lastChild.textContent=a===""&&n.photos?.length&&!e.querySelector("img")?i.photoSent:a};function de(){y.hidden=!0,r.clearTimeout(ie)}function Ie(e,{announce:n}){if(ce.has(e.seq))return;ce.add(e.seq),K=Math.max(K,e.seq);let a=Re(e.id);a||(a=N(e.id,e.role),e.role==="owner"&&qe(a,e.by||T)),a.classList.remove("is-streaming","is-pending","is-failed"),a.querySelector(".pmw-chat__retry")?.remove(),E(a,e,e.text),e.role==="visitor"?(L.delete(e.id),e.photos?.length&&X.add(e.id)):(de(),n&&(Se.textContent=H(i.said,{name:Pe(e),text:e.text})),k.hidden&&s.setAttribute("data-unread","")),M()}function ot(e,n){de();let a=Re(e);a||(a=N(e,"bot"),a.classList.add("is-streaming"),E(a,{role:"bot"},"")),a.lastChild.textContent+=n,M()}let M=()=>{w.scrollTop=w.scrollHeight};if(f){E(N("demo-question","visitor"),{role:"visitor"},Et(ge,B));let e=N("demo-answer","bot");E(e,{role:"bot"},i.demoAnswer),r.addEventListener("hashchange",()=>{Me=Ne(),z(),E(e,{role:"bot"},i.demoAnswer)})}function Y(e){if(e.failed=!0,e.li.classList.remove("is-pending"),e.li.classList.add("is-failed"),e.li.querySelector(".pmw-chat__retry"))return;let n=o(t,"span",{class:"pmw-chat__retry"},`${i.notSent} `),a=o(t,"button",{type:"button"},i.retry);a.addEventListener("click",()=>{e.failed=!1,e.tries=0,n.remove(),e.li.classList.replace("is-failed","is-pending"),h?Q()?he(e):P():me()}),n.append(a),e.li.append(n)}let Q=()=>!!b&&b.readyState===1;function he(e){if(!(e.failed||!Q())){if(e.tries>=et)return Y(e);e.tries++,b.send(JSON.stringify({type:"message",id:e.id,text:e.text}))}}function P(){if(!h||A||b)return;r.clearTimeout(se);let e=r.location.protocol==="https:"?"wss:":"ws:",n=new URLSearchParams({c:h.c,k:h.k}),a=new d(`${e}//${r.location.host}/api/chat/ws?${n}`);b=a,a.onopen=()=>{re=0,C(""),a.send(JSON.stringify({type:"hello",since:K}));for(let c of L.values())he(c)},a.onmessage=c=>{let p;try{p=JSON.parse(c.data)}catch{return}st(p)},a.onclose=c=>{b===a&&(b=null);for(let v of L.values())v.tries>=et&&Y(v);if(c.code>=4e3&&c.code<5e3)return rt(c.code);if(A)return;C(i.reconnecting);let p=Ze[Math.min(re,Ze.length-1)];re++,se=r.setTimeout(P,p)}}function rt(e){if(e===Qe.rpc)return W(i.reload,!1);W(i.ended,!0)}function W(e,n){A=!0,r.clearTimeout(se),C(""),Le.textContent=e,G.hidden=!n,j.hidden=!1,_.hidden=!0;for(let a of L.values())Y(a)}function ze(e){I=e.mode==="human"?"human":"bot",T=typeof e.ownerName=="string"?e.ownerName:null,z(),I==="human"&&qe(null,T),e.closed&&W(i.ended,!0)}function st(e){switch(e?.type){case"history":typeof e.ownerName=="string"&&(T=e.ownerName);for(let n of e.messages??[])Ie(n,{announce:!1});return ze(e);case"message":return Ie(e.msg,{announce:e.msg?.role!=="visitor"});case"delta":return ot(e.id,e.text);case"typing":return e.role==="visitor"?void 0:(y.hidden=!1,w.append(y),r.clearTimeout(ie),ie=r.setTimeout(de,kt),M());case"status":return ze(e);case"fallback":return Be(e.reason==="cap"?i.fallbackCap:i.fallbackDown);case"lead":return Ue();case"error":(e.code==="closed"||e.code==="deleted")&&W(i.ended,!0)}}let He=!1;function Ue(){if(!(He||h?.lead)){He=!0,h&&(h.lead=!0,Oe());try{r.pmw?.event(gt)}catch{}}}async function De(e,n){let a="";try{a=await u.token(e)}catch{a=""}let c,p=null;try{c=await m("/api/chat/start",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:a,lang:B,page:r.location.pathname,...n})}),p=await c.json()}catch{c=null}try{u.reset(e)}catch{}return c?.ok&&p?{ok:!0,data:p}:{ok:!1,error:c?.status===403?i.turnstile:c?.status===429?i.tooMany:null,cap:c?.status===409&&p?.error==="cap"}}function me(){return R||h||(C(i.connecting),R=De(_,{}).then(e=>{if(R=null,e.ok&&e.data.conversationId&&e.data.visitorKey){h={c:e.data.conversationId,k:e.data.visitorKey,botName:typeof e.data.botName=="string"?e.data.botName:null},Oe(),z(),P();return}for(let n of L.values())Y(n);if(e.error)return C(e.error);C(""),Be(e.cap?i.fallbackCap:i.fallbackDown)})),R}function Be(e){Ce.textContent=e,x.hidden=!1,_.hidden=!0,x.querySelector("input")?.focus()}x.addEventListener("submit",async e=>{e.preventDefault();let n=p=>x.elements[p].value.trim(),a={name:n("name"),contact:n("contact"),need:n("need")};if(!a.name||!a.contact||!a.need){V.textContent=i.fallbackMissing;return}V.textContent="";let c=await De(x,{fallbackLead:a});if(!c.ok){V.textContent=c.error??i.unavailable;return}x.replaceChildren(o(t,"p",{class:"pmw-chat__fb-done",role:"status"},i.fallbackSent)),Ue()});let it={413:"photoTooLarge",415:"photoUnreadable",409:"photoLimit",429:"tooMany"};async function ct(e){let n,a;try{n=await ee(e),a=await D(n)}catch{O.textContent=i.photoUnreadable;return}let c=U(),p=N(c,"visitor");p.classList.add("is-pending"),p.insertBefore(o(t,"img",{class:"pmw-chat__photo",src:a,alt:i.photoAlt}),p.lastChild),E(p,{role:"visitor"},""),M(),h||await me();let v=null;if(h){let dt=new URLSearchParams({c:h.c,k:h.k,id:c});try{v=await m(`/api/chat/photo?${dt}`,{method:"POST",headers:{"content-type":"image/jpeg"},body:n})}catch{v=null}}if(v?.ok){p.classList.remove("is-pending"),X.add(c),b||P();return}p.remove(),O.textContent=i[it[v?.status]]??i.photoFailed}q?.addEventListener("change",async()=>{let e=[...q.files??[]];if(q.value="",O.textContent="",A||f||e.length===0)return;let n=xt-X.size-le;e.length>n&&(O.textContent=i.photoLimit);let a=e.slice(0,Math.max(0,n));le+=a.length;for(let c of a)try{await ct(c)}finally{le--}});function $e(){let e=g.value.trim();if(!e||A||f)return;g.value="",Fe();let n={id:U(),text:e.slice(0,2e3),tries:0,failed:!1};n.li=N(n.id,"visitor"),n.li.classList.add("is-pending"),E(n.li,{role:"visitor"},n.text),L.set(n.id,n),M(),h?Q()?he(n):P():me()}_.addEventListener("submit",e=>{e.preventDefault(),$e()}),g.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),$e())});let Fe=()=>{g.style.height="auto",g.style.height=g.value?`${Math.min(g.scrollHeight,132)}px`:""};g.addEventListener("input",()=>{Fe();let e=Date.now();Q()&&e-Ae>yt&&(Ae=e,b.send(JSON.stringify({type:"typing"})))}),G.addEventListener("click",()=>{h=null;try{r.sessionStorage.removeItem(_e)}catch{}try{b?.close(1e3)}catch{}b=null,A=!1,K=0,ce.clear(),L.clear(),X.clear(),O.textContent="",I="bot",T=null,z();for(let e of[...w.querySelectorAll("li[data-id], .pmw-chat__relay")])e.remove();j.hidden=!0,_.hidden=!1,g.focus()});let lt=()=>[...k.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest("[hidden]"));k.addEventListener("keydown",e=>{if(e.key==="Escape"){e.preventDefault(),ue();return}if(e.key!=="Tab")return;let n=lt();if(n.length===0)return;let a=n[0],c=n[n.length-1];e.shiftKey&&t.activeElement===a?(e.preventDefault(),c.focus()):!e.shiftKey&&t.activeElement===c&&(e.preventDefault(),a.focus())}),F.addEventListener("click",()=>ue());function pt(){f||u.load(),k.hidden=!1,s.setAttribute("data-open",""),s.removeAttribute("data-unread"),h&&P(),(f?F:_.hidden?x.hidden?G:x.querySelector("input"):g)?.focus(),M()}function ue(){k.hidden=!0,s.removeAttribute("data-open"),tt?.focus()}return s.pmwChat={open:pt,close:ue,panel:k},s.pmwChat}export{Ze as BACKOFF_MS,gt as GOAL,xt as MAX_PHOTOS,et as MAX_TRIES,bt as PHOTO_EDGE,vt as PHOTO_QUALITY,_e as SESSION_KEY,Et as demoQuestion,Jt as mountChat};
1
+ var ot=`.pmw-chat__panel{position:fixed;z-index:81;inset:0;display:flex;flex-direction:column;background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-fg, #1f2328);font-family:var(--pmw-chat-font, inherit);font-size:16px;line-height:1.45}.pmw-chat__panel[hidden],.pmw-chat__panel [hidden]{display:none!important}@media(min-width:640px){.pmw-chat__panel{inset:auto;right:calc(env(safe-area-inset-right,0px) + var(--pmw-chat-right, 16px));bottom:calc(env(safe-area-inset-bottom,0px) + var(--pmw-chat-bottom, 16px));width:380px;height:min(600px,calc(100dvh - 16px - env(safe-area-inset-bottom,0px) - var(--pmw-chat-bottom, 16px)));border:1px solid var(--pmw-chat-line, #e6e9f0);border-radius:20px;overflow:hidden;box-shadow:0 18px 48px #1018282e}}.pmw-chat__panel :where(button){font:inherit;cursor:pointer}.pmw-chat__panel :focus-visible{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__head{display:flex;align-items:center;gap:12px;padding:14px 10px 14px 16px;border-bottom:1px solid var(--pmw-chat-line, #e6e9f0)}@media(max-width:639.98px){.pmw-chat__head{padding:12px 8px 12px 16px}}.pmw-chat__face{position:relative;flex:none;display:flex}.pmw-chat__head .pmw-chat__avatar{width:44px;height:44px;box-shadow:none}.pmw-chat__head .pmw-chat__avatar--icon{box-shadow:0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent)}.pmw-chat__head .pmw-chat__avatar img{width:44px;height:44px}.pmw-chat__head .pmw-chat__avatar--logo img{width:32px;height:32px}.pmw-chat__head .pmw-chat__avatar svg{width:100%;height:100%}.pmw-chat__dot{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37);box-shadow:0 0 0 2px var(--pmw-chat-panel-bg, #ffffff)}.pmw-chat__who{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.pmw-chat__name{margin:0;font-weight:600;font-size:16px}.pmw-chat__role,.pmw-chat__notice{margin:0;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__panel[data-mode=human] .pmw-chat__role{color:var(--pmw-chat-live, #1a7f37);font-weight:500}.pmw-chat__notice a{color:var(--pmw-chat-link, #0a1b3d);text-decoration:underline;text-underline-offset:2px}.pmw-chat__close{flex:none;width:44px;height:44px;padding:0;border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;background:transparent;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__close:hover{background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__close svg{width:18px;height:18px}.pmw-chat__body{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding:18px 16px;background:var(--pmw-chat-thread-bg, #f7f8fb)}.pmw-chat__log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}.pmw-chat__log>li{margin-top:4px}.pmw-chat__log>li:first-child,.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--bot+.pmw-chat__typing,.pmw-chat__msg--owner+.pmw-chat__msg--owner,.pmw-chat__msg--owner+.pmw-chat__typing,.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{margin-top:0}.pmw-chat__msg{max-width:82%;padding:10px 14px;border-radius:18px;white-space:pre-wrap;overflow-wrap:anywhere}.pmw-chat__msg--bot,.pmw-chat__msg--owner{align-self:flex-start;border-bottom-left-radius:6px;color:var(--pmw-chat-fg, #1f2328)}.pmw-chat__msg--bot{background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__msg--owner{background:var(--pmw-chat-owner-bg, #eef2fa);border:1px solid var(--pmw-chat-owner-border, #d5ddef)}.pmw-chat__msg--visitor{align-self:flex-end;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff);border-bottom-right-radius:6px}.pmw-chat__msg--bot+.pmw-chat__msg--bot,.pmw-chat__msg--owner+.pmw-chat__msg--owner{border-top-left-radius:6px}.pmw-chat__msg--visitor+.pmw-chat__msg--visitor{border-top-right-radius:6px}.pmw-chat__msg:has(>.pmw-chat__photo){padding:4px}.pmw-chat__msg:has(>.pmw-chat__photo) .pmw-chat__text:empty{display:none}.pmw-chat__msg.is-pending{opacity:.75}.pmw-chat__msg.is-failed{outline:2px solid var(--pmw-chat-error, #b42318);outline-offset:2px}.pmw-chat__retry{display:block;margin-top:4px;font-size:14px}.pmw-chat__retry button{padding:0;border:0;background:none;color:inherit;text-decoration:underline}.pmw-chat__typing{align-self:flex-start;display:flex;gap:5px;padding:14px 16px;border-radius:18px 18px 18px 6px;background:var(--pmw-chat-other-bg, #ffffff);border:1px solid var(--pmw-chat-line, #e6e9f0)}.pmw-chat__typing span{width:7px;height:7px;border-radius:50%;background:var(--pmw-chat-muted, #4b5563);animation:pmw-chat-dot 1.2s infinite ease-in-out}.pmw-chat__typing span:nth-child(2){animation-delay:.15s}.pmw-chat__typing span:nth-child(3){animation-delay:.3s}@keyframes pmw-chat-dot{0%,80%,to{opacity:.3}40%{opacity:1}}@media(prefers-reduced-motion:reduce){.pmw-chat__typing span{animation:none;opacity:.7}}.pmw-chat__log>.pmw-chat__relay,.pmw-chat__log>.pmw-chat__handback{align-self:stretch;margin:18px 0 8px;display:flex;align-items:center;gap:10px;font-size:13px;font-weight:500;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__relay:before,.pmw-chat__relay:after,.pmw-chat__handback:before,.pmw-chat__handback:after{content:"";flex:1;min-width:16px;height:1px;background:var(--pmw-chat-rule, #cfd5e1)}.pmw-chat__relay-label{display:flex;align-items:center;gap:6px;text-align:center}.pmw-chat__relay-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--pmw-chat-live, #1a7f37)}.pmw-chat__relay-dot--back{background:var(--pmw-chat-muted, #4b5563)}.pmw-chat__log>.pmw-chat__relay+li,.pmw-chat__log>.pmw-chat__handback+li{margin-top:0}.pmw-chat__state{margin:8px 0 0;font-size:14px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__state:empty{display:none}.pmw-chat__ended{margin-top:22px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center}.pmw-chat__ended:before{content:"";align-self:stretch;height:1px;background:var(--pmw-chat-line, #e6e9f0)}.pmw-chat__ended p{margin:0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__restart{min-height:44px;padding:0 20px;border-radius:22px;border:1px solid var(--pmw-chat-link, #0a1b3d);background:var(--pmw-chat-panel-bg, #ffffff);color:var(--pmw-chat-link, #0a1b3d);font-weight:600}.pmw-chat__composer{--pmw-chat-gap: 8px;display:flex;flex-direction:column;gap:var(--pmw-chat-gap);padding:14px 16px 16px;border-top:1px solid var(--pmw-chat-line, #e6e9f0);background:var(--pmw-chat-panel-bg, #ffffff)}@media(max-width:639.98px){.pmw-chat__composer{padding:12px 16px calc(14px + env(safe-area-inset-bottom,0px))}}.pmw-chat__box{display:flex;align-items:flex-end;gap:6px;padding:4px;border-radius:24px;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__box textarea{flex:1;min-width:0;box-sizing:border-box;min-height:44px;max-height:132px;resize:none;margin:0;padding:10px 2px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:16px;line-height:1.5}.pmw-chat__box textarea::placeholder{color:#6b7280;opacity:1}@supports selector(:has(*)){.pmw-chat__box textarea:focus-visible{outline:none}.pmw-chat__box:has(textarea:focus-visible){outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}}.pmw-chat__send{flex:none;min-height:44px;padding:0 18px;border:0;border-radius:22px;font-weight:600;background:var(--pmw-chat-accent, #0a1b3d);color:var(--pmw-chat-accent-fg, #ffffff)}.pmw-chat__send:disabled,.pmw-chat__box textarea:disabled{cursor:not-allowed;opacity:.6}.pmw-chat__check{display:grid;grid-template-rows:0fr;margin-bottom:calc(-1 * var(--pmw-chat-gap, 0px));transition:grid-template-rows .25s ease,margin-bottom .25s ease}.pmw-chat__check[data-ask]{grid-template-rows:1fr;margin-bottom:4px}.pmw-chat__check-in{min-height:0;overflow:hidden}@media(prefers-reduced-motion:reduce){.pmw-chat__check{transition:none}}.pmw-chat__attach{flex:none;position:relative;width:44px;height:44px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:inherit}.pmw-chat__attach:hover{background:#eef0f4}.pmw-chat__attach svg{width:22px;height:22px}.pmw-chat__attach:focus-within{outline:3px solid var(--pmw-chat-focus, #0a1b3d);outline-offset:2px}.pmw-chat__hint{margin:0 8px;font-size:13px;line-height:1.4;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__composer .pmw-chat__photo-error{margin:0 8px}.pmw-chat__photo{display:block;max-width:100%;max-height:220px;border-radius:14px}.pmw-chat__fallback{--pmw-chat-gap: 14px;display:flex;flex-direction:column;gap:var(--pmw-chat-gap);margin-top:16px}.pmw-chat__fb-title{margin:0;font-size:20px;font-weight:600;line-height:1.3}.pmw-chat__fb-intro{margin:-8px 0 0;font-size:15px;color:var(--pmw-chat-muted, #4b5563)}.pmw-chat__field{display:flex;flex-direction:column;gap:4px}.pmw-chat__field label{font-size:14px;font-weight:600}.pmw-chat__field input,.pmw-chat__field textarea{font:inherit;font-size:16px;padding:10px 14px;border-radius:12px;resize:none;border:1px solid var(--pmw-chat-field-border, #7d8590);background:#fff;color:var(--pmw-chat-field-fg, #1f2328)}.pmw-chat__fallback .pmw-chat__send{align-self:stretch;min-height:48px;border-radius:24px}.pmw-chat__error{margin:0;font-size:14px;color:var(--pmw-chat-error, #b42318)}.pmw-chat__error:empty{display:none}.pmw-chat__sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.pmw-chat[data-unread] .pmw-chat__bubble{position:relative}.pmw-chat[data-unread] .pmw-chat__bubble:after{content:"";position:absolute;top:2px;left:38px;width:12px;height:12px;border-radius:50%;background:var(--pmw-chat-error, #b42318);box-shadow:0 0 0 2px #fff}
2
+ `;var At="400px 0px";var rt=new Set,ce=null;function st(r,{doc:l=document,attrs:n={}}={}){if(rt.has(r)||(rt.add(r),l.querySelector(`script[src="${r}"]`)))return;let t=l.createElement("script");t.src=r,t.async=!0;for(let[m,p]of Object.entries(n))t.setAttribute(m,p);l.head.append(t)}function it({doc:r=document,hostname:l=globalThis.location?.hostname??""}={}){let n=r.getElementById("pmw-hosts");if(!n)return!1;let t;try{t=JSON.parse(n.textContent??"")}catch{return!1}if(!Array.isArray(t))return!1;let m=String(l).toLowerCase();return t.some(p=>typeof p=="string"&&p.toLowerCase()===m)}function Ot(r){return ce||(ce=new Promise(l=>{let n=null,t=null,m=0,p=r.PerformanceObserver?.supportedEntryTypes??[],d=typeof r.PerformanceObserver=="function"&&p.includes("paint"),f=()=>{n===null||d&&t===null||l(Math.max(n,t??0,m))};if(d)try{new r.PerformanceObserver(v=>{for(let T of v.getEntries())T.name==="first-contentful-paint"&&(t=T.startTime);f()}).observe({type:"paint",buffered:!0}),p.includes("largest-contentful-paint")&&new r.PerformanceObserver(v=>{for(let T of v.getEntries())m=Math.max(m,T.startTime)}).observe({type:"largest-contentful-paint",buffered:!0})}catch{t=0}let E=()=>{n=r.performance.now(),f()};r.document.readyState==="complete"?E():r.addEventListener("load",E,{once:!0})}),ce)}function Le(r,l,{win:n=window,rootMargin:t=At,settleMs:m=1500}={}){let p=null,d=!1,f=()=>{d||(d=!0,p?.disconnect(),r.removeEventListener("focusin",f),r.removeEventListener("pointerdown",f),l())};r.addEventListener("focusin",f),r.addEventListener("pointerdown",f);let E=Ot(n),v=async()=>{let T=await E;n.setTimeout(f,Math.max(0,T+m-n.performance.now()))};return typeof n.IntersectionObserver=="function"?(p=new n.IntersectionObserver(T=>{T.some(L=>L.isIntersecting)&&(p.disconnect(),v())},{rootMargin:t}),p.observe(r)):v(),{run:f}}var Mt="https://challenges.cloudflare.com/turnstile/v0/api.js";var Nt=15e3,ct=200;function Ce(r=document){st(Mt,{doc:r})}var Pt=r=>r.querySelector('input[name="cf-turnstile-response"]')?.value??"",Rt=r=>{let l=r.querySelector(".cf-turnstile");return!!l&&l.offsetHeight>0};async function lt(r,{win:l=window,timeoutMs:n=Nt}={}){typeof r.pmwTurnstileLoad=="function"?r.pmwTurnstileLoad():Ce(l.document);let t=0;for(;Pt(r)==="";){if(!Rt(r)){if(t>=n)return!1;t+=ct}await new Promise(m=>l.setTimeout(m,ct))}return!0}var pt={unauthorized:4401,expired:4410,rpc:4426,deleted:4404};var qt={fr:{assistant:"Assistant virtuel",openLabel:"Assistant virtuel, ouvrir le clavardage",dismissTeaser:"Masquer ce message",close:"Fermer le clavardage",panelTitle:"Clavardage",automated:"R\xE9ponses automatiques, tir\xE9es du contenu de ce site.",privacy:"Confidentialit\xE9",you:"Vous",messageLabel:"Votre message",send:"Envoyer",live:"Vous r\xE9pond en direct",relay:"{name} a pris le relais",relayGeneric:"Une personne de l\u2019\xE9quipe a pris le relais",handback:"L\u2019assistant virtuel a repris la conversation",oneMoment:"Un instant\u2026",reconnecting:"Connexion perdue, reconnexion\u2026",notSent:"Non envoy\xE9.",retry:"R\xE9essayer",ended:"Cette conversation est termin\xE9e.",newConversation:"Nouvelle conversation",reload:"Rechargez la page pour continuer.",unavailable:"Un probl\xE8me est survenu. R\xE9essayez dans un moment.",turnstile:"L\u2019envoi n\u2019a pas abouti. R\xE9essayez.",tooMany:"Un instant, puis r\xE9essayez.",fallbackTitle:"Laissez-nous vos coordonn\xE9es",fallbackCap:"Laissez vos coordonn\xE9es et nous vous recontacterons.",fallbackDown:"Laissez vos coordonn\xE9es et nous vous recontacterons.",name:"Votre nom",contact:"T\xE9l\xE9phone ou courriel",need:"Votre demande",fallbackSend:"Envoyer la demande",fallbackSent:"Merci, votre demande a \xE9t\xE9 transmise.",fallbackMissing:"Remplissez les trois champs.",said:"{name}\xA0: {text}",attachPhoto:"Joindre une photo",photoHint:"\xC9vitez les photos de personnes ou de pi\xE8ces d\u2019identit\xE9.",photoAlt:"Votre photo",photoSent:"Photo envoy\xE9e",photoLimit:"Trois photos au plus par conversation.",photoTooLarge:"Cette photo est trop lourde.",photoUnreadable:"Ce fichier n\u2019est pas une photo lisible.",photoFailed:"La photo n\u2019a pas pu \xEAtre envoy\xE9e. R\xE9essayez.",demoAsk:"Pouvez-vous m\u2019en dire plus sur {topic}?",demoQuestion:"Quels sont vos d\xE9lais?",demoAnswer:"(D\xE9monstration) Ici, l\u2019assistant r\xE9pond \xE0 partir du contenu de votre site.",demoHint:"Aper\xE7u\xA0: la conversation est d\xE9sactiv\xE9e."},en:{assistant:"Virtual assistant",openLabel:"Virtual assistant, open the chat",dismissTeaser:"Hide this message",close:"Close the chat",panelTitle:"Chat",automated:"Automatic answers, drawn from this site\u2019s content.",privacy:"Privacy",you:"You",messageLabel:"Your message",send:"Send",live:"Replying live",relay:"{name} took over",relayGeneric:"A team member took over",handback:"The virtual assistant is back in the conversation",oneMoment:"One moment\u2026",reconnecting:"Connection lost, reconnecting\u2026",notSent:"Not sent.",retry:"Retry",ended:"This conversation has ended.",newConversation:"New conversation",reload:"Reload the page to continue.",unavailable:"Something went wrong. Try again in a moment.",turnstile:"Sending did not go through. Try again.",tooMany:"One moment, then try again.",fallbackTitle:"Leave us your details",fallbackCap:"Leave your details and we will get back to you.",fallbackDown:"Leave your details and we will get back to you.",name:"Your name",contact:"Phone or email",need:"Your request",fallbackSend:"Send the request",fallbackSent:"Thank you, your request was sent.",fallbackMissing:"Fill in all three fields.",said:"{name}: {text}",attachPhoto:"Attach a photo",photoHint:"Avoid photos of people or of identity documents.",photoAlt:"Your photo",photoSent:"Photo sent",photoLimit:"Three photos at most per conversation.",photoTooLarge:"This photo is too large.",photoUnreadable:"This file is not a readable photo.",photoFailed:"The photo could not be sent. Try again.",demoAsk:"Can you tell me more about {topic}?",demoQuestion:"What are your lead times?",demoAnswer:"(Demo) Here, the assistant answers from your site\u2019s content.",demoHint:"Preview: the conversation is turned off."}},le=r=>String(r??"").slice(0,2).toLowerCase()==="en"?"en":"fr";function Ee(r,l={}){let n=qt[le(r)],t={...n};for(let[m,p]of Object.entries(l??{}))typeof p=="string"&&m in n&&(t[m]=p);return t}var V=(r,l)=>r.replace(/\{(\w+)\}/g,(n,t)=>t in l?String(l[t]):n);var Ae="pmw-chat",ht=[1e3,2e3,4e3,8e3,16e3,3e4],mt=3,It="chat-lead",zt=3,Ht=1600,$t=.85,Dt=3e3,Ut=8e3,Bt=40,Ft=3e3,ut=8e3,Jt='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/></svg>';async function jt(r,l){let n=await r.createImageBitmap(l,{imageOrientation:"from-image"}),t=Math.min(1,Ht/Math.max(n.width,n.height)),m=Math.max(1,Math.round(n.width*t)),p=Math.max(1,Math.round(n.height*t)),d=r.document.createElement("canvas");return d.width=m,d.height=p,d.getContext("2d").drawImage(n,0,0,m,p),n.close?.(),new Promise((f,E)=>d.toBlob(v=>v?f(v):E(new Error("encode")),"image/jpeg",$t))}var Gt={fr:{nos:"vos",notre:"votre",nous:"vous"},en:{our:"your",us:"you",we:"you"}},Kt={fr:/^une question sur (.+?)\s*\?$/i,en:/^(?:a |any )?questions? (?:about|on) (.+?)\s*\?$/i};function Vt(r,l){let n=le(l),t=Ee(n),m=String(r??"").trim().match(Kt[n]);if(!m)return t.demoQuestion;let p=m[1].replace(/[\p{L}’']+/gu,d=>Gt[n][d.toLowerCase()]??d);return V(t.demoAsk,{topic:p})}var Xt='<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>';function s(r,l,n={},t){let m=r.createElement(l);for(let[p,d]of Object.entries(n))d!=null&&d!==!1&&m.setAttribute(p,d===!0?"":d);return t!==void 0&&(m.textContent=t),m}var Yt=(r,l)=>{let n=!1,t=()=>{let p=r.turnstile;if(!p?.render)return!1;for(let d of l)if(!d.childElementCount)try{p.render(d)}catch{}return!0},m=()=>{if(Ce(r.document),n||t())return;n=!0;let p=0,d=()=>{!t()&&++p<600&&r.setTimeout(d,100)};r.setTimeout(d,100)};for(let p of l){let d=p.closest("form");d&&(d.pmwTurnstileLoad=m)}return{load:m,async token(p){return await lt(p,{win:r}),p.querySelector('input[name="cf-turnstile-response"]')?.value??""},reset(p){let d=p.querySelector(".cf-turnstile");d&&!d.closest("[data-ask]")&&r.turnstile?.reset(d)}}};function la(r,l={}){if(r.pmwChat)return r.pmwChat;let n=l.win??window,t=n.document,m=l.WebSocket??n.WebSocket,p=l.fetch??((...e)=>n.fetch(...e)),d=[],f=l.turnstile??null,E=l.uuid??(()=>n.crypto.randomUUID()),v=l.shrink??(e=>jt(n,e)),T=l.preview??(e=>new Promise((a,o)=>{let c=new n.FileReader;c.onload=()=>a(String(c.result)),c.onerror=()=>o(c.error),c.readAsDataURL(e)})),L={};try{L=JSON.parse(r.querySelector("[data-pmw-chat-config]")?.textContent??"{}")}catch{}let X=le(L.lang),i=Ee(X,L.strings),Oe=r.dataset.pmwChatOpener??"",ft=r.querySelector(".pmw-chat__bubble"),w=l.demo??(L.demo===!0||!it({doc:t,hostname:n.location.hostname}));t.getElementById("pmw-chat-style")||t.head.append(s(t,"style",{id:"pmw-chat-style"},ot));let A=s(t,"div",{class:"pmw-chat__panel",role:"dialog","aria-modal":"true","aria-labelledby":"pmw-chat-name",hidden:!0}),Me=s(t,"div",{class:"pmw-chat__head"}),pe=r.querySelector(".pmw-chat__avatar")?.cloneNode(!0)??s(t,"span",{class:"pmw-chat__avatar","aria-hidden":"true"});for(let e of pe.querySelectorAll("[id]")){let a=e.id;e.id=`${a}-head`;for(let o of pe.querySelectorAll(`[mask="url(#${a})"]`))o.setAttribute("mask",`url(#${a}-head)`)}let Ne=s(t,"div",{class:"pmw-chat__who"}),Pe=s(t,"p",{class:"pmw-chat__name",id:"pmw-chat-name"}),Y=s(t,"p",{class:"pmw-chat__role"}),Re=s(t,"span",{class:"pmw-chat__face"}),qe=s(t,"span",{class:"pmw-chat__dot","aria-hidden":"true",hidden:!0}),W=s(t,"button",{type:"button",class:"pmw-chat__close","aria-label":i.close});W.innerHTML=Xt;let Q=s(t,"p",{class:"pmw-chat__notice"},i.automated);L.privacyHref&&(Q.append(" "),Q.append(s(t,"a",{href:L.privacyHref},i.privacy))),Re.append(pe,qe),Ne.append(Pe,Y,Q),Me.append(Re,Ne,W);let _=s(t,"ol",{class:"pmw-chat__log"}),wt=s(t,"li",{class:"pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting"},Oe),C=s(t,"li",{class:"pmw-chat__typing","aria-hidden":"true",hidden:!0});C.append(s(t,"span"),s(t,"span"),s(t,"span")),_.append(wt);let de=s(t,"p",{class:"pmw-chat__state",role:"status"}),Ie=s(t,"p",{class:"pmw-chat__sr","aria-live":"polite"}),ze=e=>{let a=s(t,"div",{class:"pmw-chat__check"}),o=s(t,"div",{class:"pmw-chat__check-in"}),c=`pmwChatTurnstile${e}`,h=D=>()=>{a.toggleAttribute("data-ask",D),k()};n[`${c}Ask`]=h(!0),n[`${c}Error`]=h(!0),n[`${c}Done`]=h(!1);let x=s(t,"div",{class:"cf-turnstile pmw-turnstile","data-sitekey":L.siteKey??"","data-action":"chat","data-theme":"light","data-language":X,"data-appearance":"interaction-only","data-size":"flexible","data-callback":`${c}Done`,"data-before-interactive-callback":`${c}Ask`,"data-error-callback":`${c}Error`});return d.push(x),o.append(x),a.append(o),a},g=s(t,"form",{class:"pmw-chat__composer",novalidate:!0}),b=s(t,"textarea",{id:"pmw-chat-input",name:"message",rows:"1",maxlength:String(2e3),placeholder:i.messageLabel,autocomplete:"off",disabled:w,"aria-describedby":w?"pmw-chat-demo-hint":null}),_t=s(t,"button",{type:"submit",class:"pmw-chat__send",disabled:w},i.send),he=s(t,"div",{class:"pmw-chat__box"});he.append(s(t,"label",{class:"pmw-chat__sr",for:"pmw-chat-input"},i.messageLabel),b,_t),w||g.append(ze("c")),g.append(he),w&&g.append(s(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-demo-hint"},i.demoHint));let U=null,I=s(t,"p",{class:"pmw-chat__error pmw-chat__photo-error",role:"alert"});if(L.photos===!0){let e=s(t,"label",{class:"pmw-chat__attach"});U=s(t,"input",{type:"file",class:"pmw-chat__sr",accept:"image/*",multiple:!0,"aria-describedby":"pmw-chat-photo-hint",disabled:w}),e.innerHTML=Jt,e.prepend(U),e.append(s(t,"span",{class:"pmw-chat__sr"},i.attachPhoto)),he.insertBefore(e,b),g.append(s(t,"p",{class:"pmw-chat__hint",id:"pmw-chat-photo-hint"},i.photoHint),I)}let Z=s(t,"div",{class:"pmw-chat__ended",hidden:!0}),He=s(t,"p",{},i.ended),ee=s(t,"button",{type:"button",class:"pmw-chat__restart"},i.newConversation);Z.append(He,ee);let y=s(t,"form",{class:"pmw-chat__fallback",novalidate:!0,hidden:!0,"aria-labelledby":"pmw-chat-fb-title"}),$e=s(t,"p",{class:"pmw-chat__fb-intro"}),me=(e,a,o)=>{let c=`pmw-chat-fb-${e}`,h=s(t,"div",{class:"pmw-chat__field"});return h.append(s(t,"label",{for:c},i[e]),s(t,a,{id:c,name:e,required:!0,...o})),h},B=s(t,"p",{class:"pmw-chat__error",role:"alert"});y.append(s(t,"p",{class:"pmw-chat__fb-title",id:"pmw-chat-fb-title"},i.fallbackTitle),$e,me("name","input",{autocomplete:"name",maxlength:"100"}),me("contact","input",{autocomplete:"on",maxlength:"200"}),me("need","textarea",{rows:"3",maxlength:"2000"}),B,s(t,"button",{type:"submit",class:"pmw-chat__send"},i.fallbackSend)),w||y.insertBefore(ze("f"),B);let O=s(t,"div",{class:"pmw-chat__body"});O.append(_,de,Z,y),A.append(Me,O,g,Ie),r.append(A),f??(f=Yt(n,d));let F=!0,De=0,ue=0,gt=()=>O.scrollHeight-O.scrollTop-O.clientHeight;O.addEventListener("scroll",()=>{let e=O.scrollTop;gt()<=Bt?F=!0:e<De&&(F=!1),De=e},{passive:!0});let bt=e=>n.requestAnimationFrame?n.requestAnimationFrame(e):n.setTimeout(e,16);function k(e=!1,a=!1){e&&(F=!0),!(!F||ue)&&(ue=bt(()=>{if(ue=0,!F)return;let o=a||!!n.matchMedia?.("(prefers-reduced-motion: reduce)").matches;O.scrollTo({top:O.scrollHeight,behavior:o?"instant":"smooth"})}))}if(typeof n.ResizeObserver=="function"){let e=new n.ResizeObserver(()=>k());e.observe(_),e.observe(g)}let u=null;if(!w)try{u=JSON.parse(n.sessionStorage.getItem(Ae)??"null")}catch{u=null}let Ue=()=>{try{n.sessionStorage.setItem(Ae,JSON.stringify(u))}catch{}},S=null,fe=0,we=null,J=null,te=0,j="bot",P=null,z=!1,Be=0,_e=null,ge=new Set,R=new Map,ae=new Set,be=0,Fe=()=>new URLSearchParams(n.location.hash.slice(1)).get("pmw-chat-botName")?.trim()||null,Je=w?Fe():null,xe=()=>w?Je:u?.botName||null;function G(){let e=j==="human";Pe.textContent=e?P||i.assistant:xe()||i.assistant,Y.textContent=e?i.live:xe()?i.assistant:"",Y.hidden=Y.textContent==="",Q.hidden=e,qe.hidden=!e,A.dataset.mode=j}G();let M=e=>{de.textContent=e??"",de.hidden=!e};M("");let je=e=>e.role==="visitor"?i.you:e.role==="owner"?e.by||P||i.assistant:xe()||i.assistant;function H(e,a){let o=s(t,"li",{class:`pmw-chat__msg pmw-chat__msg--${a}`,"data-id":e}),c=s(t,"span",{class:"pmw-chat__sr"}),h=s(t,"span",{class:"pmw-chat__text"});return o.append(c,h),_.insertBefore(o,C.parentNode===_?C:null),o}let Ge=()=>C.parentNode===_?C.previousElementSibling:_.lastElementChild,N=(e,a)=>e.classList.contains(a);function Ke(e,a,o,c){let h=s(t,"li",{class:e,role:"separator","aria-label":o}),x=s(t,"span",{class:"pmw-chat__relay-label","aria-hidden":"true"});x.append(s(t,"span",{class:a}),o),h.append(x),_.insertBefore(h,c??(C.parentNode===_?C:null)),k()}function Ve(e,a){for(let o=e?e.previousElementSibling:Ge();o;o=o.previousElementSibling){if(N(o,"pmw-chat__relay")||N(o,"pmw-chat__msg--owner"))return;if(N(o,"pmw-chat__msg--bot")||N(o,"pmw-chat__handback"))break}Ke("pmw-chat__relay","pmw-chat__relay-dot",a?V(i.relay,{name:a}):i.relayGeneric,e)}function ve(e){for(let a=e?e.previousElementSibling:Ge();;a=a.previousElementSibling){if(!a||N(a,"pmw-chat__handback")||N(a,"pmw-chat__msg--bot"))return;if(N(a,"pmw-chat__relay")||N(a,"pmw-chat__msg--owner"))break}Ke("pmw-chat__handback","pmw-chat__relay-dot pmw-chat__relay-dot--back",i.handback,e)}let Xe=e=>[..._.querySelectorAll("li[data-id]")].find(a=>a.dataset.id===e),q=(e,a,o)=>{e.firstChild.textContent=V(i.said,{name:je(a),text:""}),e.lastChild.textContent=o===""&&a.photos?.length&&!e.querySelector("img")?i.photoSent:o};function K(){C.hidden=!0,n.clearTimeout(_e)}function ye(e){C.hidden=!1,_.append(C),n.clearTimeout(_e),e&&(_e=n.setTimeout(K,e)),k()}function Ye(e,{announce:a}){if(ge.has(e.seq))return;ge.add(e.seq),te=Math.max(te,e.seq);let o=Xe(e.id);o||(o=H(e.id,e.role),e.role==="owner"?Ve(o,e.by||P):e.role==="bot"&&ve(o)),o.classList.remove("is-streaming","is-pending","is-failed"),o.querySelector(".pmw-chat__retry")?.remove(),q(o,e,e.text),e.role==="visitor"?(R.delete(e.id),e.photos?.length&&ae.add(e.id)):(K(),a&&(Ie.textContent=V(i.said,{name:je(e),text:e.text})),A.hidden&&r.setAttribute("data-unread","")),k()}function xt(e,a){K();let o=Xe(e);o||(o=H(e,"bot"),ve(o),o.classList.add("is-streaming"),q(o,{role:"bot"},"")),o.lastChild.textContent+=a,k()}if(w){q(H("demo-question","visitor"),{role:"visitor"},Vt(Oe,X));let e=H("demo-answer","bot");q(e,{role:"bot"},i.demoAnswer),n.addEventListener("hashchange",()=>{Je=Fe(),G(),q(e,{role:"bot"},i.demoAnswer)})}function ne(e){if(e.failed=!0,e.li.classList.remove("is-pending"),e.li.classList.add("is-failed"),e.li.querySelector(".pmw-chat__retry"))return;let a=s(t,"span",{class:"pmw-chat__retry"},`${i.notSent} `),o=s(t,"button",{type:"button"},i.retry);o.addEventListener("click",()=>{e.failed=!1,e.tries=0,a.remove(),e.li.classList.replace("is-failed","is-pending"),u?oe()?ke(e):$():Se()}),a.append(o),e.li.append(a)}let oe=()=>!!S&&S.readyState===1;function ke(e){if(!(e.failed||!oe())){if(e.tries>=mt)return ne(e);e.tries++,S.send(JSON.stringify({type:"message",id:e.id,text:e.text}))}}function $(){if(!u||z||S)return;n.clearTimeout(we);let e=n.location.protocol==="https:"?"wss:":"ws:",a=new URLSearchParams({c:u.c,k:u.k}),o=new m(`${e}//${n.location.host}/api/chat/ws?${a}`);S=o,o.onopen=()=>{fe=0,se&&(ie(),ye(ut)),M(""),o.send(JSON.stringify({type:"hello",since:te}));for(let c of R.values())ke(c)},o.onmessage=c=>{let h;try{h=JSON.parse(c.data)}catch{return}yt(h)},o.onclose=c=>{S===o&&(S=null);for(let x of R.values())x.tries>=mt&&ne(x);if(c.code>=4e3&&c.code<5e3)return vt(c.code);if(z)return;ie(),M(i.reconnecting);let h=ht[Math.min(fe,ht.length-1)];fe++,we=n.setTimeout($,h)}}function vt(e){if(e===pt.rpc)return re(i.reload,!1);re(i.ended,!0)}function re(e,a){z=!0,n.clearTimeout(we),M(""),He.textContent=e,ee.hidden=!a,Z.hidden=!1,g.hidden=!0;for(let o of R.values())ne(o);k()}function We(e){j=e.mode==="human"?"human":"bot",P=typeof e.ownerName=="string"?e.ownerName:null,G(),j==="human"?Ve(null,P):ve(null),e.closed&&re(i.ended,!0)}function yt(e){switch(e?.type){case"history":typeof e.ownerName=="string"&&(P=e.ownerName);for(let a of e.messages??[])Ye(a,{announce:!1});return We(e);case"message":return Ye(e.msg,{announce:e.msg?.role!=="visitor"});case"delta":return xt(e.id,e.text);case"typing":return e.role==="visitor"?void 0:ye(ut);case"status":return We(e);case"fallback":return tt(e.reason==="cap"?i.fallbackCap:i.fallbackDown);case"lead":return Ze();case"error":(e.code==="closed"||e.code==="deleted")&&re(i.ended,!0)}}let Qe=!1;function Ze(){if(!(Qe||u?.lead)){Qe=!0,u&&(u.lead=!0,Ue());try{n.pmw?.event(It)}catch{}}}async function et(e,a){let o="";try{o=await f.token(e)}catch{o=""}let c,h=null;try{c=await p("/api/chat/start",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:o,lang:X,page:n.location.pathname,...a})}),h=await c.json()}catch{c=null}try{f.reset(e)}catch{}return c?.ok&&h?{ok:!0,data:h}:{ok:!1,error:c?.status===403?i.turnstile:c?.status===429?i.tooMany:null,cap:c?.status===409&&h?.error==="cap"}}let se=null,ie=()=>{n.clearTimeout(se),se=null};function Se(){return J||u||(M(""),ye(),se=n.setTimeout(()=>M(i.oneMoment),Ut),J=et(g,{}).then(e=>{if(J=null,e.ok&&e.data.conversationId&&e.data.visitorKey){u={c:e.data.conversationId,k:e.data.visitorKey,botName:typeof e.data.botName=="string"?e.data.botName:null},Ue(),G(),$();return}ie(),K();for(let a of R.values())ne(a);if(e.error)return M(e.error);M(""),tt(e.cap?i.fallbackCap:i.fallbackDown)})),J}function tt(e){$e.textContent=e,y.hidden=!1,g.hidden=!0,y.querySelector("input")?.focus()}y.addEventListener("submit",async e=>{e.preventDefault();let a=h=>y.elements[h].value.trim(),o={name:a("name"),contact:a("contact"),need:a("need")};if(!o.name||!o.contact||!o.need){B.textContent=i.fallbackMissing;return}B.textContent="";let c=await et(y,{fallbackLead:o});if(!c.ok){B.textContent=c.error??i.unavailable;return}y.replaceChildren(s(t,"p",{class:"pmw-chat__fb-done",role:"status"},i.fallbackSent)),Ze()});let kt={413:"photoTooLarge",415:"photoUnreadable",409:"photoLimit",429:"tooMany"};async function St(e){let a,o;try{a=await v(e),o=await T(a)}catch{I.textContent=i.photoUnreadable;return}let c=E(),h=H(c,"visitor");h.classList.add("is-pending");let x=s(t,"img",{class:"pmw-chat__photo",src:o,alt:i.photoAlt});x.addEventListener("load",()=>k(),{once:!0}),h.insertBefore(x,h.lastChild),q(h,{role:"visitor"},""),k(!0),u||await Se();let D=null;if(u){let Ct=new URLSearchParams({c:u.c,k:u.k,id:c});try{D=await p(`/api/chat/photo?${Ct}`,{method:"POST",headers:{"content-type":"image/jpeg"},body:a})}catch{D=null}}if(D?.ok){h.classList.remove("is-pending"),ae.add(c),S||$();return}h.remove(),I.textContent=i[kt[D?.status]]??i.photoFailed}U?.addEventListener("change",async()=>{let e=[...U.files??[]];if(U.value="",I.textContent="",z||w||e.length===0)return;let a=zt-ae.size-be;e.length>a&&(I.textContent=i.photoLimit);let o=e.slice(0,Math.max(0,a));be+=o.length;for(let c of o)try{await St(c)}finally{be--}});function at(){let e=b.value.trim();if(!e||z||w)return;b.value="",nt();let a={id:E(),text:e.slice(0,2e3),tries:0,failed:!1};a.li=H(a.id,"visitor"),a.li.classList.add("is-pending"),q(a.li,{role:"visitor"},a.text),R.set(a.id,a),k(!0),u?oe()?ke(a):$():Se()}g.addEventListener("submit",e=>{e.preventDefault(),at()}),b.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),at())});let nt=()=>{b.style.height="auto",b.style.height=b.value?`${Math.min(b.scrollHeight,132)}px`:""};b.addEventListener("input",()=>{nt();let e=Date.now();oe()&&e-Be>Ft&&(Be=e,S.send(JSON.stringify({type:"typing"})))}),ee.addEventListener("click",()=>{u=null;try{n.sessionStorage.removeItem(Ae)}catch{}try{S?.close(1e3)}catch{}S=null,z=!1,te=0,ge.clear(),R.clear(),ae.clear(),I.textContent="",j="bot",P=null,G();for(let e of[..._.querySelectorAll("li[data-id], .pmw-chat__relay, .pmw-chat__handback")])e.remove();K(),ie(),Z.hidden=!0,g.hidden=!1,b.focus()});let Tt=()=>[...A.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest("[hidden]"));A.addEventListener("keydown",e=>{if(e.key==="Escape"){e.preventDefault(),Te();return}if(e.key!=="Tab")return;let a=Tt();if(a.length===0)return;let o=a[0],c=a[a.length-1];e.shiftKey&&t.activeElement===o?(e.preventDefault(),c.focus()):!e.shiftKey&&t.activeElement===c&&(e.preventDefault(),o.focus())}),W.addEventListener("click",()=>Te());function Lt(){w||f.load(),A.hidden=!1,r.setAttribute("data-open",""),r.removeAttribute("data-unread"),u&&$(),(w?W:g.hidden?y.hidden?ee:y.querySelector("input"):b)?.focus(),k(!0,!0)}function Te(){A.hidden=!0,r.removeAttribute("data-open"),ft?.focus()}return w||Le(r,()=>f.load(),{win:n,settleMs:Dt}),r.pmwChat={open:Lt,close:Te,panel:A},r.pmwChat}export{ht as BACKOFF_MS,It as GOAL,zt as MAX_PHOTOS,mt as MAX_TRIES,Ht as PHOTO_EDGE,$t as PHOTO_QUALITY,Bt as PIN_SLACK,Ae as SESSION_KEY,Ut as SLOW_START_MS,Dt as WARM_MS,Vt as demoQuestion,la as mountChat};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/plugin-chat",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "PmeWeb chat plugin: the bubble, the lazy chat panel, the site Worker's /api/chat routes and the build-time context file. The conversations, the model and the alerts live in the pmeweb-chat service.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/strings.js CHANGED
@@ -23,7 +23,8 @@ export const STRINGS = {
23
23
  live: 'Vous répond en direct',
24
24
  relay: '{name} a pris le relais',
25
25
  relayGeneric: 'Une personne de l’équipe a pris le relais',
26
- connecting: 'Connexion…',
26
+ handback: 'L’assistant virtuel a repris la conversation',
27
+ oneMoment: 'Un instant…',
27
28
  reconnecting: 'Connexion perdue, reconnexion…',
28
29
  notSent: 'Non envoyé.',
29
30
  retry: 'Réessayer',
@@ -70,7 +71,8 @@ export const STRINGS = {
70
71
  live: 'Replying live',
71
72
  relay: '{name} took over',
72
73
  relayGeneric: 'A team member took over',
73
- connecting: 'Connecting…',
74
+ handback: 'The virtual assistant is back in the conversation',
75
+ oneMoment: 'One moment…',
74
76
  reconnecting: 'Connection lost, reconnecting…',
75
77
  notSent: 'Not sent.',
76
78
  retry: 'Retry',
package/src/trigger.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // The inline part of the chat (dist/trigger.iife.js, under the manifest's inlineBudget). At page
2
2
  // open it fetches nothing: it shows the bubble on every host and schedules the opener. The panel
3
- // (dist/widget.js) is imported on the first touch of the bubble or the opener (@pmeweb/loader
4
- // onInteraction: press, hover, focus, key) and opened on a click. Off the site's real hosts the
3
+ // (dist/widget.js) is imported on the visitor's first sign of life ANYWHERE on the page
4
+ // (@pmeweb/loader onInteraction on the document, PAGE_EVENTS: a press, a focus, a key, a pointer
5
+ // moving, a scroll) and opened on a click. A test run (Lighthouse, PageSpeed) never does any of
6
+ // these, so it never fetches the widget before the opener's 20 s (the opener appearing imports it
7
+ // too: the visitor may open it next). Importing it that early is what lets the widget start
8
+ // Turnstile well before the panel opens (widget.js, « TURNSTILE »). Off the site's real hosts the
5
9
  // panel runs its demo (manifest preview: test); the widget decides that, not this file.
6
10
  //
7
11
  // THE DEMO FOLLOWS THE URL FRAGMENT, off the real hosts only (phase 4 spec 2.4), so the portal's
@@ -21,11 +25,13 @@
21
25
  // `inert` (out of the tab order and the accessibility tree): chat.css fades it out, then turns it
22
26
  // `visibility: hidden`. Never while the panel is open or while focus is inside the root: a focusout
23
27
  // re-checks, so closing the panel near the footer hides the bubble once focus moves on.
24
- import { isCanonical, onInteraction as interact } from '@pmeweb/loader'
28
+ import { INTERACTION_EVENTS, isCanonical, onInteraction as interact } from '@pmeweb/loader'
25
29
 
26
30
  export const OPENER_KEY = 'pmw-chat-opener'
27
31
  export const OPENER_DELAY_MS = 20000
28
32
  export const OPENER_SCROLL = 0.3
33
+ /** The page-wide signs of a person: the loader's own, plus a pointer moving and a scroll. */
34
+ export const PAGE_EVENTS = [...INTERACTION_EVENTS, 'pointermove', 'scroll']
29
35
 
30
36
  export function startChat({
31
37
  doc = document, win = window, hostname = win.location.hostname, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
@@ -36,13 +42,16 @@ export function startChat({
36
42
  const teaser = root.querySelector('.pmw-chat__teaser')
37
43
  let widget = null
38
44
  const load = () => (widget ??= importLazy(root.dataset.pmwLazy).then((m) => m.mountChat(root), (e) => { widget = null; throw e }))
39
- onInteraction(root, () => load().catch(() => {}))
45
+ const warm = () => load().catch(() => {})
46
+ const open = () => load().then((w) => w.open(), () => {})
47
+ const isOpen = () => root.hasAttribute('data-open')
48
+ onInteraction(doc, warm, { events: PAGE_EVENTS })
40
49
  const canonical = isCanonical({ doc, hostname })
41
50
  if (!canonical) {
42
51
  const view = () => {
43
52
  const v = new URLSearchParams(win.location.hash.slice(1)).get('pmw-chat')
44
- if (v === 'panel' && !root.hasAttribute('data-open')) load().then((w) => w.open(), () => {})
45
- else if (v === 'bubble' && widget) widget.then((w) => w.close(), () => {})
53
+ if (v === 'panel' && !isOpen()) open()
54
+ else if (v === 'bubble' && widget) widget.then((w) => w.close?.(), () => {})
46
55
  }
47
56
  view()
48
57
  win.addEventListener('hashchange', view)
@@ -50,7 +59,7 @@ export function startChat({
50
59
 
51
60
  let away = false
52
61
  const sync = (f = doc.activeElement) => {
53
- const h = away && !root.hasAttribute('data-open') && !root.contains(f)
62
+ const h = away && !isOpen() && !root.contains(f)
54
63
  root.toggleAttribute('inert', h)
55
64
  }
56
65
  const zone = doc.querySelector('[data-pmw-chat-avoid]') ?? [...doc.querySelectorAll('footer')].pop()
@@ -70,16 +79,17 @@ export function startChat({
70
79
  try { store?.setItem(OPENER_KEY, '1') } catch { /* nothing to remember with */ }
71
80
  }
72
81
  const show = () => {
73
- if (done || away || !teaser || root.hasAttribute('data-open')) return
82
+ if (done || away || !teaser || isOpen()) return
74
83
  finish()
75
84
  teaser.hidden = false
85
+ warm()
76
86
  }
77
87
  root.addEventListener('click', (e) => {
78
88
  const t = e.target.closest?.('[data-pmw-chat-open],[data-pmw-chat-dismiss]')
79
89
  if (!t) return
80
90
  if (teaser) teaser.hidden = true
81
91
  finish()
82
- if (t.hasAttribute('data-pmw-chat-open')) load().then((w) => w.open(), () => {})
92
+ if (t.hasAttribute('data-pmw-chat-open')) open()
83
93
  })
84
94
  if (!done && teaser && canonical) {
85
95
  win.setTimeout(show, OPENER_DELAY_MS)
package/src/widget.css CHANGED
@@ -97,15 +97,17 @@
97
97
  @media (prefers-reduced-motion: reduce) {
98
98
  .pmw-chat__typing span { animation: none; opacity: 0.7; }
99
99
  }
100
- /* Where a person takes over: a line, the green dot and « {name} a pris le relais », centred. */
101
- .pmw-chat__log > .pmw-chat__relay {
100
+ /* Where a person takes over: a line, the green dot and « {name} a pris le relais », centred. Where
101
+ they hand back: the same line, the dot in the muted colour (nobody is live any more). */
102
+ .pmw-chat__log > .pmw-chat__relay, .pmw-chat__log > .pmw-chat__handback {
102
103
  align-self: stretch; margin: 18px 0 8px; display: flex; align-items: center; gap: 10px;
103
104
  font-size: 13px; font-weight: 500; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563);
104
105
  }
105
- .pmw-chat__relay::before, .pmw-chat__relay::after { content: ''; flex: 1; min-width: 16px; height: 1px; background: var(--pmw-chat-rule, #cfd5e1); }
106
+ .pmw-chat__relay::before, .pmw-chat__relay::after, .pmw-chat__handback::before, .pmw-chat__handback::after { content: ''; flex: 1; min-width: 16px; height: 1px; background: var(--pmw-chat-rule, #cfd5e1); }
106
107
  .pmw-chat__relay-label { display: flex; align-items: center; gap: 6px; text-align: center; }
107
108
  .pmw-chat__relay-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--pmw-chat-live, #1a7f37); }
108
- .pmw-chat__log > .pmw-chat__relay + li { margin-top: 0; }
109
+ .pmw-chat__relay-dot--back { background: var(--pmw-chat-muted, #4b5563); }
110
+ .pmw-chat__log > .pmw-chat__relay + li, .pmw-chat__log > .pmw-chat__handback + li { margin-top: 0; }
109
111
  .pmw-chat__state { margin: 8px 0 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
110
112
  .pmw-chat__state:empty { display: none; }
111
113
  /* The end: a thin line, the sentence centred, an outlined pill. */
@@ -117,13 +119,16 @@
117
119
  background: var(--pmw-chat-panel-bg, #ffffff); color: var(--pmw-chat-link, #0a1b3d); font-weight: 600;
118
120
  }
119
121
 
120
- /* The composer: one rounded white field holding the photo button, the text and the send pill. */
122
+ /* The composer: one rounded white field holding the photo button, the text and the send pill.
123
+ 16 px on each side, so the field's focus ring (3 px, 2 px out) keeps 11 px off the panel's edge;
124
+ on a phone the bottom adds the home indicator's safe area. */
121
125
  .pmw-chat__composer {
122
- display: flex; flex-direction: column; gap: 6px; padding: 12px 12px 10px;
126
+ --pmw-chat-gap: 8px;
127
+ display: flex; flex-direction: column; gap: var(--pmw-chat-gap); padding: 14px 16px 16px;
123
128
  border-top: 1px solid var(--pmw-chat-line, #e6e9f0); background: var(--pmw-chat-panel-bg, #ffffff);
124
129
  }
125
130
  @media (max-width: 639.98px) {
126
- .pmw-chat__composer { padding: 10px 12px calc(14px + env(safe-area-inset-bottom, 0px)); }
131
+ .pmw-chat__composer { padding: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px)); }
127
132
  }
128
133
  .pmw-chat__box {
129
134
  display: flex; align-items: flex-end; gap: 6px; padding: 4px; border-radius: 24px;
@@ -145,7 +150,20 @@
145
150
  background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
146
151
  }
147
152
  .pmw-chat__send:disabled, .pmw-chat__box textarea:disabled { cursor: not-allowed; opacity: 0.6; }
148
- .pmw-chat__composer .pmw-turnstile, .pmw-chat__fallback .pmw-turnstile { align-self: stretch; }
153
+ /* Turnstile's row (widget.js « TURNSTILE »): collapsed, with no height and no gap, until Cloudflare
154
+ asks the visitor to act (its before-interactive or error callback sets data-ask); collapsed again
155
+ on success. Full width, aligned with the field (the widget is data-size flexible, 300 px minimum:
156
+ the composer's inside is 358 px on a 390 px phone, 346 px in the 380 px desktop panel). In the
157
+ composer it sits ABOVE the field, which therefore never moves when it opens or closes. */
158
+ .pmw-chat__check {
159
+ display: grid; grid-template-rows: 0fr; margin-bottom: calc(-1 * var(--pmw-chat-gap, 0px));
160
+ transition: grid-template-rows 0.25s ease, margin-bottom 0.25s ease;
161
+ }
162
+ .pmw-chat__check[data-ask] { grid-template-rows: 1fr; margin-bottom: 4px; }
163
+ .pmw-chat__check-in { min-height: 0; overflow: hidden; }
164
+ @media (prefers-reduced-motion: reduce) {
165
+ .pmw-chat__check { transition: none; }
166
+ }
149
167
  /* The photo picker: a real file input, visually hidden inside its label, so Tab reaches it and the
150
168
  label shows its focus ring. 44 x 44 like every other control. */
151
169
  .pmw-chat__attach {
@@ -160,7 +178,7 @@
160
178
  .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 14px; }
161
179
 
162
180
  /* The contact form, when the chat cannot answer: a 20 px title, rounded fields, a full-width pill. */
163
- .pmw-chat__fallback { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; }
181
+ .pmw-chat__fallback { --pmw-chat-gap: 14px; display: flex; flex-direction: column; gap: var(--pmw-chat-gap); margin-top: 16px; }
164
182
  .pmw-chat__fb-title { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; }
165
183
  .pmw-chat__fb-intro { margin: -8px 0 0; font-size: 15px; color: var(--pmw-chat-muted, #4b5563); }
166
184
  .pmw-chat__field { display: flex; flex-direction: column; gap: 4px; }
package/src/widget.js CHANGED
@@ -37,8 +37,8 @@
37
37
  // a visitor's question drawn from it (demoQuestion) and a canned answer, with the composer disabled
38
38
  // and a hint saying why. The client sees their face and colours in the « Contenu » preview.
39
39
  import css from './widget.css?inline'
40
- import { isCanonical } from '@pmeweb/loader'
41
- import { addScript as loadTurnstile, resetToken, tokenReady } from '@pmeweb/turnstile'
40
+ import { isCanonical, onApproach } from '@pmeweb/loader'
41
+ import { addScript as loadTurnstile, tokenReady } from '@pmeweb/turnstile'
42
42
  import { CLOSE, MAX_TEXT } from './protocol.js'
43
43
  import { fill, langOf, stringsFor } from './strings.js'
44
44
 
@@ -49,6 +49,12 @@ export const GOAL = 'chat-lead'
49
49
  export const MAX_PHOTOS = 3
50
50
  export const PHOTO_EDGE = 1600
51
51
  export const PHOTO_QUALITY = 0.85
52
+ /** How long after the page has painted (and the widget was imported) Turnstile starts, unasked. */
53
+ export const WARM_MS = 3000
54
+ /** A first start slower than this shows « Un instant… » under the typing dots. */
55
+ export const SLOW_START_MS = 8000
56
+ /** How close to the bottom of the thread (px) still counts as « at the bottom ». */
57
+ export const PIN_SLACK = 40
52
58
  const TYPING_SEND_MS = 3000
53
59
  const TYPING_SHOW_MS = 8000
54
60
 
@@ -90,14 +96,58 @@ function el(doc, tag, attrs = {}, text) {
90
96
  return e
91
97
  }
92
98
 
93
- const defaultTurnstile = (win) => ({
94
- load: () => loadTurnstile(win.document),
95
- async token(form) {
96
- await tokenReady(form, { win })
97
- return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
98
- },
99
- reset: (form) => resetToken(form, { win }),
100
- })
99
+ // TURNSTILE (operator, 2026-11-13). Loaded only when the panel opened, the challenge had no time to
100
+ // observe the visitor and often showed its « Vérifiez que vous êtes humain » box. So it now starts
101
+ // early, but never in a test run: the trigger imports this file on the visitor's first sign of life
102
+ // on the page (a scroll, a pointer moving, a press, a key), and mountChat then starts api.js through
103
+ // @pmeweb/loader onApproach on the chat root (fixed on screen): once the page has painted (`load`,
104
+ // first contentful paint, LCP, and this file's own import, whichever is latest) plus WARM_MS, or at
105
+ // once on a press or a focus in the chat, or when the panel opens. Lighthouse never interacts, so it
106
+ // never imports this file and never fetches api.js; a visitor who reads for a few seconds before
107
+ // opening the panel usually finds the token minted.
108
+ //
109
+ // THE WIDGETS RENDER HIDDEN, in the closed panel (a display:none iframe still runs: measured with
110
+ // Cloudflare's test keys, a token in ~1.9 s). Each sits in a row that is COLLAPSED until Cloudflare
111
+ // itself asks for the visitor: its `before-interactive` and `error` callbacks open the row, its
112
+ // success callback collapses it again (so « Succès ! » goes away), and a later expiry that needs a
113
+ // click opens it again. The rule « never hide the box with a guessed rule » holds: the row follows
114
+ // Cloudflare's callbacks, never the iframe's size. Implicit rendering only scans the page once, when
115
+ // api.js runs, so a widget added after a contact form already loaded api.js is rendered here.
116
+ //
117
+ // THE TOKEN: Turnstile refreshes an unused one before its 300 s expiry (refresh-expired: auto); after
118
+ // each post the widget is reset (a token is single-use), only while its row is not asking: a reset
119
+ // would wipe the box the visitor is solving. `reset(el)` names our widget, never every widget on the
120
+ // page (@pmeweb/turnstile rule 4). Its own resetToken is not used: it keys on the box's height, and
121
+ // a solved box keeps its height inside our collapsed row.
122
+ const defaultTurnstile = (win, widgets) => {
123
+ let polling = false
124
+ const render = () => {
125
+ const ts = win.turnstile
126
+ if (!ts?.render) return false
127
+ for (const w of widgets) if (!w.childElementCount) try { ts.render(w) } catch { /* left to tokenReady's timeout */ }
128
+ return true
129
+ }
130
+ const load = () => {
131
+ loadTurnstile(win.document)
132
+ if (polling || render()) return
133
+ polling = true
134
+ let n = 0
135
+ const tick = () => { if (!render() && ++n < 600) win.setTimeout(tick, 100) }
136
+ win.setTimeout(tick, 100)
137
+ }
138
+ for (const w of widgets) { const f = w.closest('form'); if (f) f.pmwTurnstileLoad = load }
139
+ return {
140
+ load,
141
+ async token(form) {
142
+ await tokenReady(form, { win })
143
+ return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
144
+ },
145
+ reset(form) {
146
+ const w = form.querySelector('.cf-turnstile')
147
+ if (w && !w.closest('[data-ask]')) win.turnstile?.reset(w)
148
+ },
149
+ }
150
+ }
101
151
 
102
152
  export function mountChat(root, deps = {}) {
103
153
  if (root.pmwChat) return root.pmwChat
@@ -105,7 +155,8 @@ export function mountChat(root, deps = {}) {
105
155
  const doc = win.document
106
156
  const WS = deps.WebSocket ?? win.WebSocket
107
157
  const fetchImpl = deps.fetch ?? ((...a) => win.fetch(...a))
108
- const turnstile = deps.turnstile ?? defaultTurnstile(win)
158
+ const widgets = []
159
+ let turnstile = deps.turnstile ?? null
109
160
  const uuid = deps.uuid ?? (() => win.crypto.randomUUID())
110
161
  const shrink = deps.shrink ?? ((file) => shrinkPhoto(win, file))
111
162
  // A data: URL, not a blob: one: site CSPs allow `img-src data:` (boilerplate worker), not blob:.
@@ -164,19 +215,37 @@ export function mountChat(root, deps = {}) {
164
215
  const state = el(doc, 'p', { class: 'pmw-chat__state', role: 'status' })
165
216
  const announcer = el(doc, 'p', { class: 'pmw-chat__sr', 'aria-live': 'polite' })
166
217
 
167
- const widget = (form) => el(doc, 'div', {
168
- class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
169
- 'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
170
- })
218
+ // A widget in its row (see « TURNSTILE » above). Implicit rendering reads callbacks as global
219
+ // function names, one set per widget (`c` the composer, `f` the contact form).
220
+ const widget = (key) => {
221
+ const row = el(doc, 'div', { class: 'pmw-chat__check' })
222
+ const inner = el(doc, 'div', { class: 'pmw-chat__check-in' })
223
+ const cb = `pmwChatTurnstile${key}`
224
+ const ask = (on) => () => { row.toggleAttribute('data-ask', on); follow() }
225
+ win[`${cb}Ask`] = ask(true)
226
+ win[`${cb}Error`] = ask(true)
227
+ win[`${cb}Done`] = ask(false)
228
+ const w = el(doc, 'div', {
229
+ class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
230
+ 'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
231
+ 'data-callback': `${cb}Done`, 'data-before-interactive-callback': `${cb}Ask`, 'data-error-callback': `${cb}Error`,
232
+ })
233
+ widgets.push(w)
234
+ inner.append(w)
235
+ row.append(inner)
236
+ return row
237
+ }
171
238
  const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
172
239
  const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '1', maxlength: String(MAX_TEXT), placeholder: s.messageLabel, autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
173
240
  const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
174
241
  // One rounded field holds the photo button, the text and the send button; hints and errors go under it.
175
242
  const box = el(doc, 'div', { class: 'pmw-chat__box' })
176
243
  box.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
244
+ // The check row sits ABOVE the field: the composer is pinned to the panel's bottom, so the row
245
+ // opening or collapsing never moves the field the visitor is typing in.
246
+ if (!demo) composer.append(widget('c'))
177
247
  composer.append(box)
178
248
  if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
179
- else composer.append(widget(composer))
180
249
  let picker = null
181
250
  const photoError = el(doc, 'p', { class: 'pmw-chat__error pmw-chat__photo-error', role: 'alert' })
182
251
  if (cfg.photos === true) {
@@ -210,11 +279,49 @@ export function mountChat(root, deps = {}) {
210
279
  field('need', 'textarea', { rows: '3', maxlength: '2000' }),
211
280
  fbError, el(doc, 'button', { type: 'submit', class: 'pmw-chat__send' }, s.fallbackSend),
212
281
  )
213
- if (!demo) fallback.append(widget(fallback))
282
+ if (!demo) fallback.insertBefore(widget('f'), fbError)
214
283
  const body = el(doc, 'div', { class: 'pmw-chat__body' })
215
284
  body.append(log, state, ended, fallback)
216
285
  panel.append(head, body, composer, announcer)
217
286
  root.append(panel)
287
+ turnstile ??= defaultTurnstile(win, widgets)
288
+
289
+ // ---- following the thread ---------------------------------------------------------------
290
+ // PINNED (operator, 2026-11-13): while the visitor is at the bottom (within PIN_SLACK), every
291
+ // change that adds height (a streamed fragment, the stored message replacing it, the typing dots,
292
+ // a photo once decoded, the takeover line, the end block, the composer growing or its check row
293
+ // opening) keeps the thread at the bottom. A visitor who scrolled UP to read stays where they are;
294
+ // reaching the bottom again pins it again. Sending a message is explicit: it always pins. The
295
+ // scroll is smooth, one per animation frame however many fragments land in it (a stream would
296
+ // otherwise stack dozens), and instant under prefers-reduced-motion and when the panel opens.
297
+ // Our own scrolls only ever move down, so a scroll that moves UP away from the bottom is the
298
+ // visitor's.
299
+ let pinned = true
300
+ let lastTop = 0
301
+ let followAt = 0
302
+ const gap = () => body.scrollHeight - body.scrollTop - body.clientHeight
303
+ body.addEventListener('scroll', () => {
304
+ const t = body.scrollTop
305
+ if (gap() <= PIN_SLACK) pinned = true
306
+ else if (t < lastTop) pinned = false
307
+ lastTop = t
308
+ }, { passive: true })
309
+ const nextFrame = (f) => (win.requestAnimationFrame ? win.requestAnimationFrame(f) : win.setTimeout(f, 16))
310
+ function follow(force = false, instant = false) {
311
+ if (force) pinned = true
312
+ if (!pinned || followAt) return
313
+ followAt = nextFrame(() => {
314
+ followAt = 0
315
+ if (!pinned) return
316
+ const still = instant || !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches
317
+ body.scrollTo({ top: body.scrollHeight, behavior: still ? 'instant' : 'smooth' })
318
+ })
319
+ }
320
+ if (typeof win.ResizeObserver === 'function') {
321
+ const ro = new win.ResizeObserver(() => follow())
322
+ ro.observe(log)
323
+ ro.observe(composer)
324
+ }
218
325
 
219
326
  // ---- state ------------------------------------------------------------------------------
220
327
  let session = null
@@ -269,18 +376,37 @@ export function mountChat(root, deps = {}) {
269
376
  // scan back stops at a line already there or at an owner message, so one takeover gets one line
270
377
  // however often the frames repeat it; the bot answering again and a person taking over again
271
378
  // gets another.
272
- function relay(at, by) {
273
- let x = at ? at.previousElementSibling : (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
274
- for (; x; x = x.previousElementSibling) {
275
- if (x.classList.contains('pmw-chat__relay') || x.classList.contains('pmw-chat__msg--owner')) return
276
- if (x.classList.contains('pmw-chat__msg--bot')) break
277
- }
278
- const text = by ? fill(s.relay, { name: by }) : s.relayGeneric
279
- const sep = el(doc, 'li', { class: 'pmw-chat__relay', role: 'separator', 'aria-label': text })
379
+ //
380
+ // THE HAND-BACK (operator, 2026-11-13) is its mirror: where the owner gives the conversation back
381
+ // (« Rendre à l’assistant »), one line « L’assistant virtuel a repris la conversation », always
382
+ // the « assistant virtuel » wording (never the bot's name alone: it must not pass for a person).
383
+ // It goes before the first bot message that follows a person (live, and on replay), or at the end
384
+ // when the mode turns back to bot before the bot speaks. Its scan back stops at a hand-back line
385
+ // already there or at a bot message (nothing to mark), and inserts only past an owner message or a
386
+ // takeover line: a conversation the bot never left gets none. A takeover after it gets its own line.
387
+ const lastLine = () => (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
388
+ const isA = (x, c) => x.classList.contains(c)
389
+ function separator(cls, dotCls, text, at) {
390
+ const sep = el(doc, 'li', { class: cls, role: 'separator', 'aria-label': text })
280
391
  const label = el(doc, 'span', { class: 'pmw-chat__relay-label', 'aria-hidden': 'true' })
281
- label.append(el(doc, 'span', { class: 'pmw-chat__relay-dot' }), text)
392
+ label.append(el(doc, 'span', { class: dotCls }), text)
282
393
  sep.append(label)
283
394
  log.insertBefore(sep, at ?? (typing.parentNode === log ? typing : null))
395
+ follow()
396
+ }
397
+ function relay(at, by) {
398
+ for (let x = at ? at.previousElementSibling : lastLine(); x; x = x.previousElementSibling) {
399
+ if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) return
400
+ if (isA(x, 'pmw-chat__msg--bot') || isA(x, 'pmw-chat__handback')) break
401
+ }
402
+ separator('pmw-chat__relay', 'pmw-chat__relay-dot', by ? fill(s.relay, { name: by }) : s.relayGeneric, at)
403
+ }
404
+ function handback(at) {
405
+ for (let x = at ? at.previousElementSibling : lastLine(); ; x = x.previousElementSibling) {
406
+ if (!x || isA(x, 'pmw-chat__handback') || isA(x, 'pmw-chat__msg--bot')) return
407
+ if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) break
408
+ }
409
+ separator('pmw-chat__handback', 'pmw-chat__relay-dot pmw-chat__relay-dot--back', s.handback, at)
284
410
  }
285
411
  const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
286
412
  const setText = (li, m, text) => {
@@ -292,6 +418,14 @@ export function mountChat(root, deps = {}) {
292
418
  typing.hidden = true
293
419
  win.clearTimeout(typingTimer)
294
420
  }
421
+ /** The dots, until a reply hides them; `ms` also hides them after that long without one. */
422
+ function showTyping(ms) {
423
+ typing.hidden = false
424
+ log.append(typing)
425
+ win.clearTimeout(typingTimer)
426
+ if (ms) typingTimer = win.setTimeout(hideTyping, ms)
427
+ follow()
428
+ }
295
429
  function render(m, { announce }) {
296
430
  if (seen.has(m.seq)) return
297
431
  seen.add(m.seq)
@@ -300,6 +434,7 @@ export function mountChat(root, deps = {}) {
300
434
  if (!li) {
301
435
  li = line(m.id, m.role)
302
436
  if (m.role === 'owner') relay(li, m.by || ownerName)
437
+ else if (m.role === 'bot') handback(li)
303
438
  }
304
439
  li.classList.remove('is-streaming', 'is-pending', 'is-failed')
305
440
  li.querySelector('.pmw-chat__retry')?.remove()
@@ -313,20 +448,20 @@ export function mountChat(root, deps = {}) {
313
448
  if (announce) announcer.textContent = fill(s.said, { name: speaker(m), text: m.text })
314
449
  if (panel.hidden) root.setAttribute('data-unread', '')
315
450
  }
316
- scroll()
451
+ follow()
317
452
  }
318
453
  function delta(id, text) {
319
454
  hideTyping()
320
455
  let li = find(id)
321
456
  if (!li) {
322
457
  li = line(id, 'bot')
458
+ handback(li)
323
459
  li.classList.add('is-streaming')
324
460
  setText(li, { role: 'bot' }, '')
325
461
  }
326
462
  li.lastChild.textContent += text
327
- scroll()
463
+ follow()
328
464
  }
329
- const scroll = () => { log.scrollTop = log.scrollHeight }
330
465
  if (demo) {
331
466
  setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
332
467
  const answer = line('demo-answer', 'bot')
@@ -375,6 +510,8 @@ export function mountChat(root, deps = {}) {
375
510
  ws = sock
376
511
  sock.onopen = () => {
377
512
  attempt = 0
513
+ // A first start: the dots stay until the reply, for TYPING_SHOW_MS at most, like the service's.
514
+ if (slowTimer) { notSlow(); showTyping(TYPING_SHOW_MS) }
378
515
  showState('')
379
516
  sock.send(JSON.stringify({ type: 'hello', since: lastSeq }))
380
517
  for (const entry of pending.values()) transmit(entry)
@@ -389,6 +526,7 @@ export function mountChat(root, deps = {}) {
389
526
  for (const entry of pending.values()) if (entry.tries >= MAX_TRIES) failed(entry)
390
527
  if (e.code >= 4000 && e.code < 5000) return refused(e.code)
391
528
  if (over) return
529
+ notSlow()
392
530
  showState(s.reconnecting)
393
531
  const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
394
532
  attempt++
@@ -408,12 +546,14 @@ export function mountChat(root, deps = {}) {
408
546
  ended.hidden = false
409
547
  composer.hidden = true
410
548
  for (const entry of pending.values()) failed(entry)
549
+ follow()
411
550
  }
412
551
  function status(m) {
413
552
  mode = m.mode === 'human' ? 'human' : 'bot'
414
553
  ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
415
554
  header()
416
555
  if (mode === 'human') relay(null, ownerName)
556
+ else handback(null)
417
557
  if (m.closed) end(s.ended, true)
418
558
  }
419
559
  function frame(m) {
@@ -428,11 +568,7 @@ export function mountChat(root, deps = {}) {
428
568
  return delta(m.id, m.text)
429
569
  case 'typing':
430
570
  if (m.role === 'visitor') return
431
- typing.hidden = false
432
- log.append(typing)
433
- win.clearTimeout(typingTimer)
434
- typingTimer = win.setTimeout(hideTyping, TYPING_SHOW_MS)
435
- return scroll()
571
+ return showTyping(TYPING_SHOW_MS)
436
572
  case 'status':
437
573
  return status(m)
438
574
  case 'fallback':
@@ -473,9 +609,19 @@ export function mountChat(root, deps = {}) {
473
609
  const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
474
610
  return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
475
611
  }
612
+ // THE FIRST START (operator, 2026-11-13) reads like any reply: the typing dots at once, no
613
+ // « Connexion… ». Only a start slower than SLOW_START_MS adds « Un instant… »; the socket's open
614
+ // clears it, a drop says « Connexion perdue, reconnexion… » as before. Nothing is started before
615
+ // the visitor writes: pmeweb-chat counts every /start toward the visitor's conversations of the day
616
+ // (src/ipLimit.ts there), so a panel opened and never used must not spend one. Only the token is
617
+ // prepared ahead (« TURNSTILE » above).
618
+ let slowTimer = null
619
+ const notSlow = () => { win.clearTimeout(slowTimer); slowTimer = null }
476
620
  function begin() {
477
621
  if (starting || session) return starting
478
- showState(s.connecting)
622
+ showState('')
623
+ showTyping()
624
+ slowTimer = win.setTimeout(() => showState(s.oneMoment), SLOW_START_MS)
479
625
  starting = post(composer, {}).then((r) => {
480
626
  starting = null
481
627
  if (r.ok && r.data.conversationId && r.data.visitorKey) {
@@ -485,6 +631,8 @@ export function mountChat(root, deps = {}) {
485
631
  connect()
486
632
  return
487
633
  }
634
+ notSlow()
635
+ hideTyping()
488
636
  for (const entry of pending.values()) failed(entry)
489
637
  if (r.error) return showState(r.error)
490
638
  showState('')
@@ -532,9 +680,12 @@ export function mountChat(root, deps = {}) {
532
680
  const id = uuid()
533
681
  const li = line(id, 'visitor')
534
682
  li.classList.add('is-pending')
535
- li.insertBefore(el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt }), li.lastChild)
683
+ const img = el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt })
684
+ // Its height is known only once decoded: re-pin then (follow() is a no-op if they scrolled up).
685
+ img.addEventListener('load', () => follow(), { once: true })
686
+ li.insertBefore(img, li.lastChild)
536
687
  setText(li, { role: 'visitor' }, '')
537
- scroll()
688
+ follow(true)
538
689
  if (!session) await begin()
539
690
  let res = null
540
691
  if (session) {
@@ -580,7 +731,7 @@ export function mountChat(root, deps = {}) {
580
731
  entry.li.classList.add('is-pending')
581
732
  setText(entry.li, { role: 'visitor' }, entry.text)
582
733
  pending.set(entry.id, entry)
583
- scroll()
734
+ follow(true)
584
735
  if (!session) begin()
585
736
  else if (isOpen()) transmit(entry)
586
737
  else connect()
@@ -616,7 +767,9 @@ export function mountChat(root, deps = {}) {
616
767
  mode = 'bot'
617
768
  ownerName = null
618
769
  header()
619
- for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay')]) li.remove()
770
+ for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay, .pmw-chat__handback')]) li.remove()
771
+ hideTyping()
772
+ notSlow()
620
773
  ended.hidden = true
621
774
  composer.hidden = false
622
775
  input.focus()
@@ -644,7 +797,7 @@ export function mountChat(root, deps = {}) {
644
797
  root.removeAttribute('data-unread')
645
798
  if (session) connect()
646
799
  ;(demo ? closeBtn : composer.hidden ? (fallback.hidden ? restart : fallback.querySelector('input')) : input)?.focus()
647
- scroll()
800
+ follow(true, true)
648
801
  }
649
802
  function close() {
650
803
  panel.hidden = true
@@ -652,6 +805,9 @@ export function mountChat(root, deps = {}) {
652
805
  bubble?.focus()
653
806
  }
654
807
 
808
+ // The early start (« TURNSTILE » above). Off the real hosts there is nothing to start.
809
+ if (!demo) onApproach(root, () => turnstile.load(), { win, settleMs: WARM_MS })
810
+
655
811
  root.pmwChat = { open: show, close, panel }
656
812
  return root.pmwChat
657
813
  }
package/src/worker.js CHANGED
@@ -32,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
32
32
  import { RPC_HEADER, RPC_VERSION } from './protocol.js'
33
33
 
34
34
  /** This package's version, reported by the health route (a test holds it equal to package.json). */
35
- export const VERSION = '1.3.0'
35
+ export const VERSION = '1.3.1'
36
36
  export { RPC_VERSION }
37
37
 
38
38
  const SERVICE = 'https://pmeweb-chat.internal'