@pmeweb/plugin-chat 1.0.0 → 1.2.0

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/src/strings.js CHANGED
@@ -21,19 +21,21 @@ export const STRINGS = {
21
21
  messageLabel: 'Votre message',
22
22
  send: 'Envoyer',
23
23
  live: 'Vous répond en direct',
24
+ relay: '{name} a pris le relais',
25
+ relayGeneric: 'Une personne de l’équipe a pris le relais',
24
26
  connecting: 'Connexion…',
25
27
  reconnecting: 'Connexion perdue, reconnexion…',
26
28
  notSent: 'Non envoyé.',
27
29
  retry: 'Réessayer',
28
30
  ended: 'Cette conversation est terminée.',
29
31
  newConversation: 'Nouvelle conversation',
30
- reload: 'Le clavardage a été mis à jour. Rechargez la page pour continuer.',
31
- unavailable: 'Le clavardage n’est pas disponible pour le moment.',
32
- turnstile: 'La vérification anti-robot n’a pas abouti. Réessayez.',
33
- tooMany: 'Trop de messages en peu de temps. Réessayez dans une minute.',
32
+ reload: 'Rechargez la page pour continuer.',
33
+ unavailable: 'Un problème est survenu. Réessayez dans un moment.',
34
+ turnstile: 'L’envoi n’a pas abouti. Réessayez.',
35
+ tooMany: 'Un instant, puis réessayez.',
34
36
  fallbackTitle: 'Laissez-nous vos coordonnées',
35
- fallbackCap: 'L’assistant ne peut plus répondre aujourd’hui. Laissez vos coordonnées et on vous recontactera.',
36
- fallbackDown: 'L’assistant ne peut pas répondre pour le moment. Laissez vos coordonnées et on vous recontactera.',
37
+ fallbackCap: 'Laissez vos coordonnées et nous vous recontacterons.',
38
+ fallbackDown: 'Laissez vos coordonnées et nous vous recontacterons.',
37
39
  name: 'Votre nom',
38
40
  contact: 'Téléphone ou courriel',
39
41
  need: 'Votre demande',
@@ -66,19 +68,21 @@ export const STRINGS = {
66
68
  messageLabel: 'Your message',
67
69
  send: 'Send',
68
70
  live: 'Replying live',
71
+ relay: '{name} took over',
72
+ relayGeneric: 'A team member took over',
69
73
  connecting: 'Connecting…',
70
74
  reconnecting: 'Connection lost, reconnecting…',
71
75
  notSent: 'Not sent.',
72
76
  retry: 'Retry',
73
77
  ended: 'This conversation has ended.',
74
78
  newConversation: 'New conversation',
75
- reload: 'The chat was updated. Reload the page to continue.',
76
- unavailable: 'The chat is not available right now.',
77
- turnstile: 'The anti-robot check did not go through. Try again.',
78
- tooMany: 'Too many messages in a short time. Try again in a minute.',
79
+ reload: 'Reload the page to continue.',
80
+ unavailable: 'Something went wrong. Try again in a moment.',
81
+ turnstile: 'Sending did not go through. Try again.',
82
+ tooMany: 'One moment, then try again.',
79
83
  fallbackTitle: 'Leave us your details',
80
- fallbackCap: 'The assistant cannot answer any more today. Leave your details and we will get back to you.',
81
- fallbackDown: 'The assistant cannot answer right now. Leave your details and we will get back to you.',
84
+ fallbackCap: 'Leave your details and we will get back to you.',
85
+ fallbackDown: 'Leave your details and we will get back to you.',
82
86
  name: 'Your name',
83
87
  contact: 'Phone or email',
84
88
  need: 'Your request',
package/src/trigger.js CHANGED
@@ -4,17 +4,31 @@
4
4
  // onInteraction: press, hover, focus, key) and opened on a click. Off the site's real hosts the
5
5
  // panel runs its demo (manifest preview: test); the widget decides that, not this file.
6
6
  //
7
+ // THE DEMO FOLLOWS THE URL FRAGMENT, off the real hosts only (phase 4 spec 2.4), so the portal's
8
+ // preview switches views without a rebuild: `#pmw-chat=panel` imports the widget at once and opens
9
+ // the panel, `#pmw-chat=bubble` closes it; read on load and on `hashchange`. The name
10
+ // (`pmw-chat-botName`) is the widget's to read. No opener is scheduled there: it would cover the
11
+ // preview the portal is showing.
12
+ //
7
13
  // The opener shows once per visit (sessionStorage), on the first of: 20 s on the page, 30 % of the
8
14
  // page scrolled, or exit intent (a fine pointer leaving through the top of the window). Never while
9
- // the panel is open, never again once dismissed or once the panel has been opened.
10
- import { onInteraction as interact } from '@pmeweb/loader'
15
+ // the panel is open, never again once dismissed or once the panel has been opened, and never while
16
+ // the footer is in view (it waits for the next trigger then). On a phone it fades out after 8 s:
17
+ // that is chat.css (an animation), not code, to keep this file under its budget.
18
+ //
19
+ // OUT OF THE FOOTER'S WAY, on the real hosts only (off them the bubble is the preview's subject).
20
+ // While the first `[data-pmw-chat-avoid]`, else the page's last `footer`, is in view, the root is
21
+ // `inert` (out of the tab order and the accessibility tree): chat.css fades it out, then turns it
22
+ // `visibility: hidden`. Never while the panel is open or while focus is inside the root: a focusout
23
+ // 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'
11
25
 
12
26
  export const OPENER_KEY = 'pmw-chat-opener'
13
27
  export const OPENER_DELAY_MS = 20000
14
28
  export const OPENER_SCROLL = 0.3
15
29
 
16
30
  export function startChat({
17
- doc = document, win = window, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
31
+ doc = document, win = window, hostname = win.location.hostname, importLazy = (u) => import(/* @vite-ignore */ u), onInteraction = interact,
18
32
  } = {}) {
19
33
  const root = doc.getElementById('pmw-chat')
20
34
  if (!root) return null
@@ -23,21 +37,40 @@ export function startChat({
23
37
  let widget = null
24
38
  const load = () => (widget ??= importLazy(root.dataset.pmwLazy).then((m) => m.mountChat(root), (e) => { widget = null; throw e }))
25
39
  onInteraction(root, () => load().catch(() => {}))
40
+ const canonical = isCanonical({ doc, hostname })
41
+ if (!canonical) {
42
+ const view = () => {
43
+ 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(), () => {})
46
+ }
47
+ view()
48
+ win.addEventListener('hashchange', view)
49
+ }
50
+
51
+ let away = false
52
+ const sync = (f = doc.activeElement) => {
53
+ const h = away && !root.hasAttribute('data-open') && !root.contains(f)
54
+ root.toggleAttribute('inert', h)
55
+ }
56
+ const zone = doc.querySelector('[data-pmw-chat-avoid]') ?? [...doc.querySelectorAll('footer')].pop()
57
+ if (canonical && zone) {
58
+ new win.IntersectionObserver((l) => { away = l.at(-1).isIntersecting; sync() }).observe(zone)
59
+ root.addEventListener('focusout', (e) => sync(e.relatedTarget))
60
+ }
26
61
 
27
62
  const store = win.sessionStorage
28
63
  let done = false
29
64
  try { done = !!store?.getItem(OPENER_KEY) } catch { /* storage blocked: show once per page */ }
30
- const listeners = []
31
- const on = (t, ev, f) => { t.addEventListener(ev, f, { passive: true }); listeners.push([t, ev, f]) }
32
- let timer
65
+ // The scroll and exit listeners stay after the opener is done: they return at once (no layout read).
66
+ const on = (t, ev, f) => t.addEventListener(ev, (e) => done || f(e), { passive: true })
67
+ // A timer that fires once the opener is done finds `done` and returns.
33
68
  const finish = () => {
34
69
  done = true
35
70
  try { store?.setItem(OPENER_KEY, '1') } catch { /* nothing to remember with */ }
36
- win.clearTimeout(timer)
37
- for (const [t, ev, f] of listeners) t.removeEventListener(ev, f)
38
71
  }
39
72
  const show = () => {
40
- if (done || !teaser || root.hasAttribute('data-open')) return
73
+ if (done || away || !teaser || root.hasAttribute('data-open')) return
41
74
  finish()
42
75
  teaser.hidden = false
43
76
  }
@@ -48,8 +81,8 @@ export function startChat({
48
81
  finish()
49
82
  if (t.hasAttribute('data-pmw-chat-open')) load().then((w) => w.open(), () => {})
50
83
  })
51
- if (!done && teaser) {
52
- timer = win.setTimeout(show, OPENER_DELAY_MS)
84
+ if (!done && teaser && canonical) {
85
+ win.setTimeout(show, OPENER_DELAY_MS)
53
86
  on(win, 'scroll', () => {
54
87
  const room = doc.documentElement.scrollHeight - win.innerHeight
55
88
  if (room > 0 && win.scrollY / room >= OPENER_SCROLL) show()
package/src/widget.css CHANGED
@@ -1,10 +1,13 @@
1
1
  /* @pmeweb/plugin-chat: the panel, injected by the lazy widget. Like chat.css, the --pmw-chat-*
2
2
  variables are not declared here: each use carries its AA default as a fallback
3
- (a11y-tools/contrast.mjs): #1f2328 on #f1f3f6 14.21:1, #ffffff on #0a1b3d 16.98:1, #4b5563 on
4
- #ffffff 7.56:1, #b42318 on #ffffff 6.57:1, #0a1b3d on #ffffff 16.98:1. Field borders #7d8590 on
5
- #ffffff 3.73:1 (form borders need 3:1). The fields stay white with their own text colour
6
- (--pmw-chat-field-fg), so a dark site's light ink never lands on them. Chat.astro sets the panel's
7
- colours from the site's design.colors, each pair checked (src/colors.js panelTheme). */
3
+ (a11y-tools/contrast.mjs): #1f2328 on #ffffff 15.8:1 and on the thread #f7f8fb 14.88:1, on the
4
+ owner's #eef2fa 14.08:1; #ffffff on #0a1b3d 16.98:1; #4b5563 on #ffffff 7.56:1 and on #f7f8fb
5
+ 7.12:1; #1a7f37 (live) on #ffffff 5.08:1 and on #f7f8fb 4.78:1; #b42318 on #ffffff 6.57:1 and on
6
+ #f7f8fb 6.19:1; #0a1b3d on #ffffff 16.98:1. The composer's field border #7d8590 on #ffffff 3.73:1
7
+ (form borders need 3:1). The fields stay white with their own text colour (--pmw-chat-field-fg),
8
+ so a dark site's light ink never lands on them. Chat.astro sets the panel's colours from the
9
+ site's design.colors, each pair checked (src/colors.js panelTheme). The lines (--pmw-chat-line,
10
+ --pmw-chat-rule) are decoration: no text is read off them. */
8
11
  .pmw-chat__panel {
9
12
  position: fixed; z-index: 81; inset: 0;
10
13
  display: flex; flex-direction: column;
@@ -17,86 +20,156 @@
17
20
  inset: auto;
18
21
  right: calc(env(safe-area-inset-right, 0px) + var(--pmw-chat-right, 16px));
19
22
  bottom: calc(env(safe-area-inset-bottom, 0px) + var(--pmw-chat-bottom, 16px));
20
- width: 380px; height: min(600px, calc(100dvh - 32px));
21
- border: 1px solid var(--pmw-chat-border, #c9ced6); border-radius: 12px; overflow: hidden;
22
- box-shadow: 0 12px 36px rgb(0 0 0 / 0.22);
23
+ width: 380px; height: min(600px, calc(100dvh - 16px - env(safe-area-inset-bottom, 0px) - var(--pmw-chat-bottom, 16px)));
24
+ border: 1px solid var(--pmw-chat-line, #e6e9f0); border-radius: 20px; overflow: hidden;
25
+ box-shadow: 0 18px 48px rgb(16 24 40 / 0.18);
23
26
  }
24
27
  }
25
- .pmw-chat__panel button { font: inherit; cursor: pointer; }
28
+ /* :where() keeps this reset at one class, so .pmw-chat__send's weight (and every later button rule) wins. */
29
+ .pmw-chat__panel :where(button) { font: inherit; cursor: pointer; }
26
30
  .pmw-chat__panel :focus-visible { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
27
- .pmw-chat__head :focus-visible { outline-color: var(--pmw-chat-accent-fg, #ffffff); }
28
31
 
32
+ /* The header: the panel's own colour, the face in its circle, the name, then one line under it
33
+ (the automated notice and the privacy link, or « Vous répond en direct » in green). */
29
34
  .pmw-chat__head {
30
- display: flex; align-items: center; gap: 10px; padding: 10px 8px 10px 12px;
31
- background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
35
+ display: flex; align-items: center; gap: 12px; padding: 14px 10px 14px 16px;
36
+ border-bottom: 1px solid var(--pmw-chat-line, #e6e9f0);
37
+ }
38
+ @media (max-width: 639.98px) {
39
+ .pmw-chat__head { padding: 12px 8px 12px 16px; }
32
40
  }
33
- .pmw-chat__head .pmw-chat__avatar { width: 40px; height: 40px; box-shadow: none; background: var(--pmw-chat-avatar-bg, var(--pmw-chat-accent-fg, #ffffff)); color: var(--pmw-chat-avatar-fg, var(--pmw-chat-accent, #0a1b3d)); }
34
- .pmw-chat__head .pmw-chat__avatar img { width: 40px; height: 40px; }
35
- .pmw-chat__who { flex: 1; min-width: 0; }
41
+ .pmw-chat__face { position: relative; flex: none; display: flex; }
42
+ .pmw-chat__head .pmw-chat__avatar { width: 44px; height: 44px; box-shadow: none; }
43
+ .pmw-chat__head .pmw-chat__avatar--icon { box-shadow: 0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent); }
44
+ .pmw-chat__head .pmw-chat__avatar img { width: 44px; height: 44px; }
45
+ .pmw-chat__head .pmw-chat__avatar--logo img { width: 32px; height: 32px; }
46
+ .pmw-chat__head .pmw-chat__avatar svg { width: 100%; height: 100%; }
47
+ .pmw-chat__dot {
48
+ position: absolute; right: -1px; bottom: -1px; width: 12px; height: 12px; border-radius: 50%;
49
+ background: var(--pmw-chat-live, #1a7f37); box-shadow: 0 0 0 2px var(--pmw-chat-panel-bg, #ffffff);
50
+ }
51
+ .pmw-chat__who { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
36
52
  .pmw-chat__name { margin: 0; font-weight: 600; font-size: 16px; }
37
- .pmw-chat__role { margin: 0; font-size: 14px; }
38
- .pmw-chat__dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--pmw-chat-live, #3fb950); box-shadow: 0 0 0 2px var(--pmw-chat-accent, #0a1b3d); }
53
+ .pmw-chat__role, .pmw-chat__notice { margin: 0; font-size: 13px; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563); }
54
+ .pmw-chat__panel[data-mode="human"] .pmw-chat__role { color: var(--pmw-chat-live, #1a7f37); font-weight: 500; }
55
+ .pmw-chat__notice a { color: var(--pmw-chat-link, #0a1b3d); text-decoration: underline; text-underline-offset: 2px; }
39
56
  .pmw-chat__close {
40
57
  flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
41
- display: flex; align-items: center; justify-content: center; background: none; color: inherit;
58
+ display: flex; align-items: center; justify-content: center; background: transparent; color: var(--pmw-chat-fg, #1f2328);
42
59
  }
60
+ .pmw-chat__close:hover { background: var(--pmw-chat-thread-bg, #f7f8fb); }
43
61
  .pmw-chat__close svg { width: 18px; height: 18px; }
44
62
 
45
- .pmw-chat__body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 10px 12px; }
46
- .pmw-chat__notice { margin: 0 0 8px; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
47
- .pmw-chat__notice a { color: var(--pmw-chat-link, #0a1b3d); text-decoration: underline; text-underline-offset: 2px; }
48
- .pmw-chat__log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
49
- .pmw-chat__msg { max-width: 85%; padding: 8px 12px; border-radius: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
50
- .pmw-chat__msg--bot, .pmw-chat__msg--owner { align-self: flex-start; background: var(--pmw-chat-other-bg, #f1f3f6); color: var(--pmw-chat-fg, #1f2328); border-bottom-left-radius: 4px; }
51
- .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: 4px; }
63
+ /* The thread: a light tint behind the bubbles. A speaker's messages in a row sit 6 px apart, a new
64
+ speaker 10 px down (the list's 6 px gap plus 4 px); the tail corner is on the speaker's side. */
65
+ .pmw-chat__body {
66
+ flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 18px 16px;
67
+ background: var(--pmw-chat-thread-bg, #f7f8fb);
68
+ }
69
+ .pmw-chat__log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
70
+ .pmw-chat__log > li { margin-top: 4px; }
71
+ .pmw-chat__log > li:first-child,
72
+ .pmw-chat__msg--bot + .pmw-chat__msg--bot, .pmw-chat__msg--bot + .pmw-chat__typing,
73
+ .pmw-chat__msg--owner + .pmw-chat__msg--owner, .pmw-chat__msg--owner + .pmw-chat__typing,
74
+ .pmw-chat__msg--visitor + .pmw-chat__msg--visitor { margin-top: 0; }
75
+ .pmw-chat__msg { max-width: 82%; padding: 10px 14px; border-radius: 18px; white-space: pre-wrap; overflow-wrap: anywhere; }
76
+ .pmw-chat__msg--bot, .pmw-chat__msg--owner { align-self: flex-start; border-bottom-left-radius: 6px; color: var(--pmw-chat-fg, #1f2328); }
77
+ .pmw-chat__msg--bot { background: var(--pmw-chat-other-bg, #ffffff); border: 1px solid var(--pmw-chat-line, #e6e9f0); }
78
+ /* A person who took over: a light tint of the accent, so their words never pass for the bot's. */
79
+ .pmw-chat__msg--owner { background: var(--pmw-chat-owner-bg, #eef2fa); border: 1px solid var(--pmw-chat-owner-border, #d5ddef); }
80
+ .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; }
81
+ .pmw-chat__msg--bot + .pmw-chat__msg--bot, .pmw-chat__msg--owner + .pmw-chat__msg--owner { border-top-left-radius: 6px; }
82
+ .pmw-chat__msg--visitor + .pmw-chat__msg--visitor { border-top-right-radius: 6px; }
83
+ .pmw-chat__msg:has(> .pmw-chat__photo) { padding: 4px; }
84
+ .pmw-chat__msg:has(> .pmw-chat__photo) .pmw-chat__text:empty { display: none; }
52
85
  .pmw-chat__msg.is-pending { opacity: 0.75; }
53
- .pmw-chat__msg.is-failed { outline: 2px solid var(--pmw-chat-error, #b42318); }
86
+ .pmw-chat__msg.is-failed { outline: 2px solid var(--pmw-chat-error, #b42318); outline-offset: 2px; }
54
87
  .pmw-chat__retry { display: block; margin-top: 4px; font-size: 14px; }
55
88
  .pmw-chat__retry button { padding: 0; border: 0; background: none; color: inherit; text-decoration: underline; }
56
- .pmw-chat__typing { align-self: flex-start; display: flex; gap: 4px; padding: 12px; border-radius: 12px; background: var(--pmw-chat-other-bg, #f1f3f6); }
57
- .pmw-chat__typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--pmw-chat-muted, #4b5563); animation: pmw-chat-dot 1.2s infinite ease-in-out; }
89
+ .pmw-chat__typing {
90
+ align-self: flex-start; display: flex; gap: 5px; padding: 14px 16px; border-radius: 18px 18px 18px 6px;
91
+ background: var(--pmw-chat-other-bg, #ffffff); border: 1px solid var(--pmw-chat-line, #e6e9f0);
92
+ }
93
+ .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; }
58
94
  .pmw-chat__typing span:nth-child(2) { animation-delay: 0.15s; }
59
95
  .pmw-chat__typing span:nth-child(3) { animation-delay: 0.3s; }
60
96
  @keyframes pmw-chat-dot { 0%, 80%, 100% { opacity: 0.3; } 40% { opacity: 1; } }
61
97
  @media (prefers-reduced-motion: reduce) {
62
98
  .pmw-chat__typing span { animation: none; opacity: 0.7; }
63
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 {
102
+ align-self: stretch; margin: 18px 0 8px; display: flex; align-items: center; gap: 10px;
103
+ font-size: 13px; font-weight: 500; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563);
104
+ }
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-label { display: flex; align-items: center; gap: 6px; text-align: center; }
107
+ .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; }
64
109
  .pmw-chat__state { margin: 8px 0 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
65
110
  .pmw-chat__state:empty { display: none; }
66
- .pmw-chat__ended { margin-top: 12px; }
67
- .pmw-chat__ended p { margin: 0 0 8px; }
111
+ /* The end: a thin line, the sentence centred, an outlined pill. */
112
+ .pmw-chat__ended { margin-top: 22px; display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
113
+ .pmw-chat__ended::before { content: ''; align-self: stretch; height: 1px; background: var(--pmw-chat-line, #e6e9f0); }
114
+ .pmw-chat__ended p { margin: 0; font-size: 15px; color: var(--pmw-chat-muted, #4b5563); }
115
+ .pmw-chat__restart {
116
+ min-height: 44px; padding: 0 20px; border-radius: 22px; border: 1px solid var(--pmw-chat-link, #0a1b3d);
117
+ background: var(--pmw-chat-panel-bg, #ffffff); color: var(--pmw-chat-link, #0a1b3d); font-weight: 600;
118
+ }
68
119
 
69
- .pmw-chat__composer { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; padding: 10px 12px; border-top: 1px solid var(--pmw-chat-border, #c9ced6); }
70
- .pmw-chat__composer textarea { flex: 1; min-width: 0; resize: none; }
71
- .pmw-chat__composer textarea, .pmw-chat__field input, .pmw-chat__field textarea {
72
- font: inherit; font-size: 16px; padding: 8px 10px; border-radius: 8px;
120
+ /* The composer: one rounded white field holding the photo button, the text and the send pill. */
121
+ .pmw-chat__composer {
122
+ display: flex; flex-direction: column; gap: 6px; padding: 12px 12px 10px;
123
+ border-top: 1px solid var(--pmw-chat-line, #e6e9f0); background: var(--pmw-chat-panel-bg, #ffffff);
124
+ }
125
+ @media (max-width: 639.98px) {
126
+ .pmw-chat__composer { padding: 10px 12px calc(14px + env(safe-area-inset-bottom, 0px)); }
127
+ }
128
+ .pmw-chat__box {
129
+ display: flex; align-items: flex-end; gap: 6px; padding: 4px; border-radius: 24px;
73
130
  border: 1px solid var(--pmw-chat-field-border, #7d8590); background: #ffffff; color: var(--pmw-chat-field-fg, #1f2328);
74
131
  }
132
+ .pmw-chat__box textarea {
133
+ flex: 1; min-width: 0; box-sizing: border-box; min-height: 44px; max-height: 132px; resize: none; margin: 0;
134
+ padding: 10px 2px; border: 0; border-radius: 0; background: transparent; color: inherit;
135
+ font: inherit; font-size: 16px; line-height: 1.5;
136
+ }
137
+ .pmw-chat__box textarea::placeholder { color: #6b7280; opacity: 1; }
138
+ /* The ring goes round the whole field, not the bare text inside it (where :has() is known). */
139
+ @supports selector(:has(*)) {
140
+ .pmw-chat__box textarea:focus-visible { outline: none; }
141
+ .pmw-chat__box:has(textarea:focus-visible) { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
142
+ }
75
143
  .pmw-chat__send {
76
- min-height: 44px; padding: 8px 16px; border: 0; border-radius: 8px; font-weight: 600;
144
+ flex: none; min-height: 44px; padding: 0 18px; border: 0; border-radius: 22px; font-weight: 600;
77
145
  background: var(--pmw-chat-accent, #0a1b3d); color: var(--pmw-chat-accent-fg, #ffffff);
78
146
  }
79
- .pmw-chat__send:disabled, .pmw-chat__composer textarea:disabled { cursor: not-allowed; opacity: 0.6; }
80
- .pmw-chat__restart { min-height: 44px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--pmw-chat-link, #0a1b3d); background: none; color: var(--pmw-chat-link, #0a1b3d); font-weight: 600; }
81
- .pmw-chat__composer .pmw-turnstile, .pmw-chat__fallback .pmw-turnstile { flex-basis: 100%; }
147
+ .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; }
82
149
  /* The photo picker: a real file input, visually hidden inside its label, so Tab reaches it and the
83
150
  label shows its focus ring. 44 x 44 like every other control. */
84
151
  .pmw-chat__attach {
85
- flex: none; position: relative; width: 44px; height: 44px; border-radius: 8px; cursor: pointer;
86
- display: flex; align-items: center; justify-content: center;
87
- border: 1px solid var(--pmw-chat-field-border, #7d8590); color: var(--pmw-chat-field-fg, #1f2328); background: #ffffff;
152
+ flex: none; position: relative; width: 44px; height: 44px; border-radius: 50%; cursor: pointer;
153
+ display: flex; align-items: center; justify-content: center; color: inherit;
88
154
  }
155
+ .pmw-chat__attach:hover { background: #eef0f4; }
89
156
  .pmw-chat__attach svg { width: 22px; height: 22px; }
90
157
  .pmw-chat__attach:focus-within { outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px; }
91
- .pmw-chat__hint { flex-basis: 100%; margin: 0; font-size: 14px; color: var(--pmw-chat-muted, #4b5563); }
92
- .pmw-chat__composer .pmw-chat__photo-error { flex-basis: 100%; }
93
- .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 8px; }
158
+ .pmw-chat__hint { margin: 0 8px; font-size: 13px; line-height: 1.4; color: var(--pmw-chat-muted, #4b5563); }
159
+ .pmw-chat__composer .pmw-chat__photo-error { margin: 0 8px; }
160
+ .pmw-chat__photo { display: block; max-width: 100%; max-height: 220px; border-radius: 14px; }
94
161
 
95
- .pmw-chat__fallback { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
96
- .pmw-chat__fb-title { margin: 0; font-weight: 600; }
97
- .pmw-chat__fb-intro { margin: 0; font-size: 15px; }
162
+ /* 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; }
164
+ .pmw-chat__fb-title { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; }
165
+ .pmw-chat__fb-intro { margin: -8px 0 0; font-size: 15px; color: var(--pmw-chat-muted, #4b5563); }
98
166
  .pmw-chat__field { display: flex; flex-direction: column; gap: 4px; }
99
167
  .pmw-chat__field label { font-size: 14px; font-weight: 600; }
168
+ .pmw-chat__field input, .pmw-chat__field textarea {
169
+ font: inherit; font-size: 16px; padding: 10px 14px; border-radius: 12px; resize: none;
170
+ border: 1px solid var(--pmw-chat-field-border, #7d8590); background: #ffffff; color: var(--pmw-chat-field-fg, #1f2328);
171
+ }
172
+ .pmw-chat__fallback .pmw-chat__send { align-self: stretch; min-height: 48px; border-radius: 24px; }
100
173
  .pmw-chat__error { margin: 0; font-size: 14px; color: var(--pmw-chat-error, #b42318); }
101
174
  .pmw-chat__error:empty { display: none; }
102
175
  .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; }
package/src/widget.js CHANGED
@@ -20,7 +20,8 @@
20
20
  // « Non envoyé » with « Réessayer ».
21
21
  //
22
22
  // The mode comes from the service: `human` shows the owner's name, « Vous répond en direct » and the
23
- // green dot; `bot` shows « Assistant virtuel » and no dot (spec § 6: a dot says a person is here).
23
+ // green dot; `bot` shows « Assistant virtuel », the automated notice and no dot (spec § 6: a dot
24
+ // says a person is here). Where a person takes over, the thread shows a line saying so (relay()).
24
25
  // `fallback` (daily cap, model down) and a refused start show the contact form, which posts the
25
26
  // same lead through /api/chat/start. The `lead` frame fires window.pmw?.event('chat-lead') once.
26
27
  //
@@ -51,7 +52,7 @@ export const PHOTO_QUALITY = 0.85
51
52
  const TYPING_SEND_MS = 3000
52
53
  const TYPING_SHOW_MS = 8000
53
54
 
54
- const CAMERA_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>'
55
+ const PHOTO_ICON = '<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>'
55
56
 
56
57
  /** The longest edge to PHOTO_EDGE, re-encoded as JPEG: the canvas drops every EXIF field. */
57
58
  async function shrinkPhoto(win, file) {
@@ -139,17 +140,22 @@ export function mountChat(root, deps = {}) {
139
140
  const who = el(doc, 'div', { class: 'pmw-chat__who' })
140
141
  const name = el(doc, 'p', { class: 'pmw-chat__name', id: 'pmw-chat-name' })
141
142
  const role = el(doc, 'p', { class: 'pmw-chat__role' })
143
+ // The green dot sits on the face's corner, so the face gets a frame the dot is placed in (the
144
+ // avatar itself clips its content to a circle).
145
+ const face = el(doc, 'span', { class: 'pmw-chat__face' })
142
146
  const dot = el(doc, 'span', { class: 'pmw-chat__dot', 'aria-hidden': 'true', hidden: true })
143
147
  const closeBtn = el(doc, 'button', { type: 'button', class: 'pmw-chat__close', 'aria-label': s.close })
144
148
  closeBtn.innerHTML = X_ICON
145
- who.append(name, role)
146
- head.append(avatar, who, dot, closeBtn)
147
-
149
+ // The automated notice and the privacy link live under the name, in bot mode (a person who took
150
+ // over is not « réponses automatiques »).
148
151
  const notice = el(doc, 'p', { class: 'pmw-chat__notice' }, s.automated)
149
152
  if (cfg.privacyHref) {
150
153
  notice.append(' ')
151
154
  notice.append(el(doc, 'a', { href: cfg.privacyHref }, s.privacy))
152
155
  }
156
+ face.append(avatar, dot)
157
+ who.append(name, role, notice)
158
+ head.append(face, who, closeBtn)
153
159
  const log = el(doc, 'ol', { class: 'pmw-chat__log' })
154
160
  const greeting = el(doc, 'li', { class: 'pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting' }, opener)
155
161
  const typing = el(doc, 'li', { class: 'pmw-chat__typing', 'aria-hidden': 'true', hidden: true })
@@ -163,9 +169,12 @@ export function mountChat(root, deps = {}) {
163
169
  'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
164
170
  })
165
171
  const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
166
- const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '2', maxlength: String(MAX_TEXT), autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
172
+ 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 })
167
173
  const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
168
- composer.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
174
+ // One rounded field holds the photo button, the text and the send button; hints and errors go under it.
175
+ const box = el(doc, 'div', { class: 'pmw-chat__box' })
176
+ box.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
177
+ composer.append(box)
169
178
  if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
170
179
  else composer.append(widget(composer))
171
180
  let picker = null
@@ -173,10 +182,10 @@ export function mountChat(root, deps = {}) {
173
182
  if (cfg.photos === true) {
174
183
  const attach = el(doc, 'label', { class: 'pmw-chat__attach' })
175
184
  picker = el(doc, 'input', { type: 'file', class: 'pmw-chat__sr', accept: 'image/*', multiple: true, 'aria-describedby': 'pmw-chat-photo-hint', disabled: demo })
176
- attach.innerHTML = CAMERA_ICON
185
+ attach.innerHTML = PHOTO_ICON
177
186
  attach.prepend(picker)
178
187
  attach.append(el(doc, 'span', { class: 'pmw-chat__sr' }, s.attachPhoto))
179
- composer.insertBefore(attach, input)
188
+ box.insertBefore(attach, input)
180
189
  composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-photo-hint' }, s.photoHint), photoError)
181
190
  }
182
191
 
@@ -203,7 +212,7 @@ export function mountChat(root, deps = {}) {
203
212
  )
204
213
  if (!demo) fallback.append(widget(fallback))
205
214
  const body = el(doc, 'div', { class: 'pmw-chat__body' })
206
- body.append(notice, log, state, ended, fallback)
215
+ body.append(log, state, ended, fallback)
207
216
  panel.append(head, body, composer, announcer)
208
217
  root.append(panel)
209
218
 
@@ -227,12 +236,16 @@ export function mountChat(root, deps = {}) {
227
236
  const photosSent = new Set()
228
237
  let photosBusy = 0
229
238
 
230
- const botName = () => session?.botName || null
239
+ // The demo's name is the portal preview's, from the fragment (phase 4 spec 2.4); never on a real host.
240
+ const fragmentName = () => new URLSearchParams(win.location.hash.slice(1)).get('pmw-chat-botName')?.trim() || null
241
+ let demoName = demo ? fragmentName() : null
242
+ const botName = () => (demo ? demoName : session?.botName || null)
231
243
  function header() {
232
244
  const human = mode === 'human'
233
245
  name.textContent = human ? (ownerName || s.assistant) : (botName() || s.assistant)
234
246
  role.textContent = human ? s.live : (botName() ? s.assistant : '')
235
247
  role.hidden = role.textContent === ''
248
+ notice.hidden = human
236
249
  dot.hidden = !human
237
250
  panel.dataset.mode = mode
238
251
  }
@@ -250,6 +263,25 @@ export function mountChat(root, deps = {}) {
250
263
  log.insertBefore(li, typing.parentNode === log ? typing : null)
251
264
  return li
252
265
  }
266
+ // Where a person takes over, one line in the thread: « {name} a pris le relais ». It goes before
267
+ // the first owner message that follows the bot (live, and when the thread is replayed after a
268
+ // reload), or at the end of the thread when the mode turns human before the person writes. The
269
+ // scan back stops at a line already there or at an owner message, so one takeover gets one line
270
+ // however often the frames repeat it; the bot answering again and a person taking over again
271
+ // 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 })
280
+ 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)
282
+ sep.append(label)
283
+ log.insertBefore(sep, at ?? (typing.parentNode === log ? typing : null))
284
+ }
253
285
  const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
254
286
  const setText = (li, m, text) => {
255
287
  li.firstChild.textContent = fill(s.said, { name: speaker(m), text: '' })
@@ -265,7 +297,10 @@ export function mountChat(root, deps = {}) {
265
297
  seen.add(m.seq)
266
298
  lastSeq = Math.max(lastSeq, m.seq)
267
299
  let li = find(m.id)
268
- if (!li) li = line(m.id, m.role)
300
+ if (!li) {
301
+ li = line(m.id, m.role)
302
+ if (m.role === 'owner') relay(li, m.by || ownerName)
303
+ }
269
304
  li.classList.remove('is-streaming', 'is-pending', 'is-failed')
270
305
  li.querySelector('.pmw-chat__retry')?.remove()
271
306
  setText(li, m, m.text)
@@ -294,7 +329,13 @@ export function mountChat(root, deps = {}) {
294
329
  const scroll = () => { log.scrollTop = log.scrollHeight }
295
330
  if (demo) {
296
331
  setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
297
- setText(line('demo-answer', 'bot'), { role: 'bot' }, s.demoAnswer)
332
+ const answer = line('demo-answer', 'bot')
333
+ setText(answer, { role: 'bot' }, s.demoAnswer)
334
+ win.addEventListener('hashchange', () => {
335
+ demoName = fragmentName()
336
+ header()
337
+ setText(answer, { role: 'bot' }, s.demoAnswer)
338
+ })
298
339
  }
299
340
 
300
341
  function failed(entry) {
@@ -372,11 +413,13 @@ export function mountChat(root, deps = {}) {
372
413
  mode = m.mode === 'human' ? 'human' : 'bot'
373
414
  ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
374
415
  header()
416
+ if (mode === 'human') relay(null, ownerName)
375
417
  if (m.closed) end(s.ended, true)
376
418
  }
377
419
  function frame(m) {
378
420
  switch (m?.type) {
379
421
  case 'history':
422
+ if (typeof m.ownerName === 'string') ownerName = m.ownerName
380
423
  for (const x of m.messages ?? []) render(x, { announce: false })
381
424
  return status(m)
382
425
  case 'message':
@@ -428,7 +471,7 @@ export function mountChat(root, deps = {}) {
428
471
  try { turnstile.reset(form) } catch { /* a token is single-use; the next post asks again */ }
429
472
  if (res?.ok && data) return { ok: true, data }
430
473
  const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
431
- return { ok: false, error }
474
+ return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
432
475
  }
433
476
  function begin() {
434
477
  if (starting || session) return starting
@@ -445,7 +488,8 @@ export function mountChat(root, deps = {}) {
445
488
  for (const entry of pending.values()) failed(entry)
446
489
  if (r.error) return showState(r.error)
447
490
  showState('')
448
- showFallback(s.unavailable)
491
+ // 409 cap: the visitor's conversations for the day are used up (pmeweb-chat counts them per IP).
492
+ showFallback(r.cap ? s.fallbackCap : s.fallbackDown)
449
493
  })
450
494
  return starting
451
495
  }
@@ -530,6 +574,7 @@ export function mountChat(root, deps = {}) {
530
574
  const text = input.value.trim()
531
575
  if (!text || over || demo) return
532
576
  input.value = ''
577
+ grow()
533
578
  const entry = { id: uuid(), text: text.slice(0, MAX_TEXT), tries: 0, failed: false }
534
579
  entry.li = line(entry.id, 'visitor')
535
580
  entry.li.classList.add('is-pending')
@@ -544,7 +589,13 @@ export function mountChat(root, deps = {}) {
544
589
  input.addEventListener('keydown', (e) => {
545
590
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit() }
546
591
  })
592
+ // The field grows with the text, up to about five lines, then scrolls.
593
+ const grow = () => {
594
+ input.style.height = 'auto'
595
+ input.style.height = input.value ? `${Math.min(input.scrollHeight, 132)}px` : ''
596
+ }
547
597
  input.addEventListener('input', () => {
598
+ grow()
548
599
  const now = Date.now()
549
600
  if (isOpen() && now - lastTyping > TYPING_SEND_MS) {
550
601
  lastTyping = now
@@ -565,7 +616,7 @@ export function mountChat(root, deps = {}) {
565
616
  mode = 'bot'
566
617
  ownerName = null
567
618
  header()
568
- for (const li of [...log.querySelectorAll('li[data-id]')]) li.remove()
619
+ for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay')]) li.remove()
569
620
  ended.hidden = true
570
621
  composer.hidden = false
571
622
  input.focus()