@pmeweb/plugin-chat 1.2.1 → 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 +91 -19
- package/README.md +55 -3
- package/dist/trigger.iife.js +1 -1
- package/dist/widget.js +2 -2
- package/manifest.json +3 -1
- package/package.json +1 -1
- package/src/build.d.ts +22 -7
- package/src/build.js +118 -35
- package/src/strings.js +4 -2
- package/src/trigger.js +19 -9
- package/src/widget.css +27 -9
- package/src/widget.js +196 -40
- package/src/worker.js +1 -1
package/src/build.js
CHANGED
|
@@ -1,58 +1,125 @@
|
|
|
1
1
|
// @pmeweb/plugin-chat/build: the site's context file for pmeweb-chat (manifest `build`:
|
|
2
2
|
// plugins/chat/context.json, one file for every language). The site renders it from a prerendered
|
|
3
|
-
// route
|
|
4
|
-
// collection entries); pmeweb-chat reads `langs[lang]` (src/context.ts there):
|
|
3
|
+
// route; pmeweb-chat reads `langs[lang]` (src/context.ts there):
|
|
5
4
|
//
|
|
6
5
|
// { "format": 1, "langs": { "fr": { "business": "...", "sections": [{ "title", "text" }] } } }
|
|
7
6
|
//
|
|
7
|
+
// Since 1.3.0 the sources are three, and only three (operator decision 2026-11-13):
|
|
8
|
+
// 1. the site's curated file, src/chat/context.<lang>.md (manifest `context`), written by Claude
|
|
9
|
+
// Code for the assistant and held current by check-plugins' fingerprint. Its `## ` headings
|
|
10
|
+
// become the sections; the first-line stamp comment is not sent.
|
|
11
|
+
// 2. the pricing text (the site's pricing source, public/pricing.md on most sites): prices are
|
|
12
|
+
// never copied into the curated file.
|
|
13
|
+
// 3. the entries of the collections the site lists in `plugins.chat.contextCollections`, one line
|
|
14
|
+
// each (title and a short summary, never the body). Blog posts never go in.
|
|
15
|
+
// The dictionary and llms.txt are no longer read (1.2.0 sent them whole: ~26 000 tokens on pmeweb.ca,
|
|
16
|
+
// mostly interface labels).
|
|
17
|
+
//
|
|
8
18
|
// DETERMINISTIC: same sources, same bytes. No date, no random id, no network, no model call (the
|
|
9
19
|
// builder holds no secret, and a rebuild must not spend money). Not public: the site's worker serves
|
|
10
20
|
// it only to a signed request (plugin-kit's pluginArtifacts), never to a visitor.
|
|
11
21
|
|
|
12
22
|
export const CONTEXT_FORMAT = 1
|
|
13
|
-
/**
|
|
23
|
+
/** A last guard on the whole file; the curated file has its own, smaller budget (MAX_CONTEXT_CHARS). */
|
|
14
24
|
export const MAX_CONTEXT_BYTES = 200 * 1024
|
|
25
|
+
/** The curated file's budget per language, stamp line excluded (equal to the manifest's `context.maxChars`). */
|
|
26
|
+
export const MAX_CONTEXT_CHARS = 16000
|
|
27
|
+
/** An entry's summary is cut here, at a word. */
|
|
28
|
+
export const SUMMARY_CHARS = 200
|
|
29
|
+
/** The fields an entry's summary is taken from, in this order, before its body. */
|
|
30
|
+
export const SUMMARY_FIELDS = ['summary', 'resume', 'résumé', 'description', 'excerpt', 'extrait', 'about', 'apropos', 'intro']
|
|
31
|
+
/** Collections that never go in the context, whatever the config says. */
|
|
32
|
+
export const NEVER_COLLECTIONS = ['blog', 'authors']
|
|
15
33
|
|
|
16
34
|
const LANGS = ['fr', 'en']
|
|
17
35
|
const TITLES = {
|
|
18
|
-
fr: {
|
|
19
|
-
en: {
|
|
36
|
+
fr: { pricing: 'Tarifs', colon: ' : ' },
|
|
37
|
+
en: { pricing: 'Pricing', colon: ': ' },
|
|
20
38
|
}
|
|
21
39
|
|
|
22
40
|
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
|
23
41
|
const oneLine = (v) => v.replace(/\s*\n\s*/g, ' ').trim()
|
|
24
42
|
|
|
25
|
-
/**
|
|
26
|
-
export function
|
|
43
|
+
/** The curated file's text, its leading HTML comments (the stamp) removed. */
|
|
44
|
+
export function stripStamp(md) {
|
|
45
|
+
return text(md).replace(/^(?:<!--[\s\S]*?-->\s*)+/, '').trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The curated file as sections: one per `## ` heading; what comes before the first one is a
|
|
50
|
+
* section titled by the `# ` heading (else `fallbackTitle`).
|
|
51
|
+
*/
|
|
52
|
+
export function contextSections(md, fallbackTitle) {
|
|
27
53
|
const out = []
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
let title = fallbackTitle
|
|
55
|
+
let lines = []
|
|
56
|
+
const flush = () => {
|
|
57
|
+
const t = lines.join('\n').trim()
|
|
58
|
+
if (t) out.push({ title, text: t })
|
|
59
|
+
lines = []
|
|
60
|
+
}
|
|
61
|
+
for (const line of stripStamp(md).split(/\r?\n/)) {
|
|
62
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line)
|
|
63
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line)
|
|
64
|
+
if (h1 && out.length === 0 && lines.every((l) => l.trim() === '')) { title = h1[1]; continue }
|
|
65
|
+
if (h2) { flush(); title = h2[1]; continue }
|
|
66
|
+
lines.push(line)
|
|
36
67
|
}
|
|
37
|
-
|
|
38
|
-
return out
|
|
68
|
+
flush()
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `plugins.chat.contextCollections` ("portfolio,services") as a list; refuses the blog and its authors. */
|
|
73
|
+
export function contextCollections(value) {
|
|
74
|
+
if (value === undefined || value === null || value === '') return []
|
|
75
|
+
if (typeof value !== 'string') throw new Error('chat : contextCollections est une chaîne, des noms de collections séparés par des virgules')
|
|
76
|
+
const names = value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
77
|
+
const never = names.filter((n) => NEVER_COLLECTIONS.includes(n))
|
|
78
|
+
if (never.length) throw new Error(`chat : ${never.join(', ')} n'entre jamais dans le contexte (les articles et leurs auteurs restent sur le site)`)
|
|
79
|
+
return names
|
|
39
80
|
}
|
|
40
81
|
|
|
41
|
-
/**
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
/** Markdown reduced to plain words: links to their text, images, emphasis and heading marks gone. */
|
|
83
|
+
function plain(md) {
|
|
84
|
+
return md
|
|
85
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
86
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
87
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
88
|
+
.replace(/[*_`>]+/g, '')
|
|
89
|
+
.replace(/\s+/g, ' ')
|
|
90
|
+
.trim()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Cut at a word, at most `max` characters plus the ellipsis. */
|
|
94
|
+
function cut(s, max) {
|
|
95
|
+
if (s.length <= max) return s
|
|
96
|
+
const head = s.slice(0, max)
|
|
97
|
+
const at = head.lastIndexOf(' ')
|
|
98
|
+
return `${(at > max * 0.6 ? head.slice(0, at) : head).replace(/[\s,;:.]+$/, '')}…`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A field's value in `lang`: a string, or a `{ fr, en }` object. */
|
|
102
|
+
function inLang(v, lang) {
|
|
103
|
+
if (typeof v === 'string') return v
|
|
104
|
+
if (v && typeof v === 'object' && typeof v[lang] === 'string') return v[lang]
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An entry's one-line summary: `summary`, else its first summary-like field, else its body; 200 characters at most. */
|
|
109
|
+
export function entrySummary(e, lang) {
|
|
110
|
+
let s = inLang(e.summary, lang)
|
|
111
|
+
for (const k of SUMMARY_FIELDS) {
|
|
112
|
+
if (text(s)) break
|
|
113
|
+
s = inLang(e.data?.[k], lang)
|
|
47
114
|
}
|
|
48
|
-
|
|
49
|
-
return
|
|
115
|
+
if (!text(s)) s = text(e.body)
|
|
116
|
+
return cut(plain(oneLine(s)), SUMMARY_CHARS)
|
|
50
117
|
}
|
|
51
118
|
|
|
52
119
|
/**
|
|
53
120
|
* @param {{
|
|
54
|
-
* langs: Partial<Record<'fr' | 'en', { business?: string,
|
|
55
|
-
* collections?: { collection: string, lang?: 'fr' | 'en', title: string, data?: Record<string, unknown>, body?: string }[],
|
|
121
|
+
* langs: Partial<Record<'fr' | 'en', { business?: string, context?: string, pricing?: string }>>,
|
|
122
|
+
* collections?: { collection: string, label?: string | Record<string, string>, lang?: 'fr' | 'en', title: string, summary?: string | Record<string, string>, data?: Record<string, unknown>, body?: string }[],
|
|
56
123
|
* }} sources an entry with no `lang` belongs to every language
|
|
57
124
|
* @returns {string} the JSON text of plugins/chat/context.json
|
|
58
125
|
*/
|
|
@@ -61,22 +128,38 @@ export function chatContext({ langs = {}, collections = [] } = {}) {
|
|
|
61
128
|
if (keys.length === 0 || keys.some((l) => !LANGS.includes(l))) {
|
|
62
129
|
throw new Error(`chat : une langue fr ou en au moins, et aucune autre (reçu ${keys.join(', ') || 'rien'})`)
|
|
63
130
|
}
|
|
131
|
+
for (const l of keys) {
|
|
132
|
+
const old = ['llms', 'dictionary'].filter((k) => langs[l]?.[k] !== undefined)
|
|
133
|
+
if (old.length) throw new Error(`chat : ${old.join(' et ')} ne sont plus lus depuis 1.3.0 ; passer context (src/chat/context.${l}.md), voir INSTALL.md « Updating from 1.2.1 »`)
|
|
134
|
+
}
|
|
135
|
+
for (const e of collections) {
|
|
136
|
+
if (NEVER_COLLECTIONS.includes(e.collection)) throw new Error(`chat : la collection ${e.collection} n'entre jamais dans le contexte`)
|
|
137
|
+
}
|
|
64
138
|
const out = { format: CONTEXT_FORMAT, langs: {} }
|
|
65
139
|
for (const lang of LANGS.filter((l) => keys.includes(l))) {
|
|
66
140
|
const src = langs[lang] ?? {}
|
|
67
141
|
const t = TITLES[lang]
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
142
|
+
const business = text(src.business)
|
|
143
|
+
const curated = stripStamp(src.context ?? '')
|
|
144
|
+
if (!curated) throw new Error(`chat : src/chat/context.${lang}.md absent ou vide (le contexte de l'assistant)`)
|
|
145
|
+
if (curated.length > MAX_CONTEXT_CHARS) throw new Error(`chat : src/chat/context.${lang}.md fait ${curated.length} caractères, au-delà de ${MAX_CONTEXT_CHARS}`)
|
|
146
|
+
const sections = contextSections(curated, business || (lang === 'fr' ? 'Entreprise' : 'Business'))
|
|
147
|
+
const pricing = text(src.pricing)
|
|
148
|
+
if (pricing) sections.push({ title: t.pricing, text: pricing })
|
|
149
|
+
// One section per collection, one line per entry, in the order the site gives them.
|
|
150
|
+
const groups = new Map()
|
|
73
151
|
for (const e of collections) {
|
|
74
152
|
if (e.lang !== undefined && e.lang !== lang) continue
|
|
75
|
-
|
|
153
|
+
const title = oneLine(text(e.title))
|
|
154
|
+
if (!title) continue
|
|
155
|
+
const label = inLang(e.label, lang) || e.collection
|
|
156
|
+
if (!groups.has(label)) groups.set(label, [])
|
|
157
|
+
const summary = entrySummary(e, lang)
|
|
158
|
+
groups.get(label).push(summary ? `- ${title}${t.colon}${summary}` : `- ${title}`)
|
|
76
159
|
}
|
|
77
|
-
|
|
160
|
+
for (const [label, lines] of groups) sections.push({ title: label, text: lines.join('\n') })
|
|
78
161
|
const part = {}
|
|
79
|
-
if (
|
|
162
|
+
if (business) part.business = business
|
|
80
163
|
part.sections = sections
|
|
81
164
|
out.langs[lang] = part
|
|
82
165
|
}
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
// onInteraction
|
|
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
|
-
|
|
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' && !
|
|
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 && !
|
|
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 ||
|
|
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'))
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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; }
|