@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/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 with sources it already has (llms.txt, its pricing text, its dictionaries, its published
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
- /** About 50k tokens at most: past this the prompt would cost too much per message. */
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: { llms: 'llms.txt', pricing: 'Tarifs', dictionary: 'Textes du site', colon: ' : ' },
19
- en: { llms: 'llms.txt', pricing: 'Pricing', dictionary: 'Site text', colon: ': ' },
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
- /** A dictionary as `path.to.key: value` lines, strings only, in the dictionary's own order. */
26
- export function flattenDictionary(dict, prefix = '') {
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
- const walk = (v, path) => {
29
- if (typeof v === 'string') {
30
- if (v.trim() !== '') out.push(`${path}: ${oneLine(v)}`)
31
- } else if (Array.isArray(v)) {
32
- v.forEach((x, i) => walk(x, path ? `${path}.${i}` : String(i)))
33
- } else if (v && typeof v === 'object') {
34
- for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k)
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
- walk(dict, prefix)
38
- return out.join('\n')
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
- /** One collection entry: its string fields as `key: value` lines (the title itself left out), then its body. */
42
- function entryText(e) {
43
- const lines = []
44
- for (const [k, v] of Object.entries(e.data ?? {})) {
45
- if (typeof v !== 'string' || v.trim() === '' || v === e.title) continue
46
- lines.push(`${k}: ${oneLine(v)}`)
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
- const body = text(e.body)
49
- return [lines.join('\n'), body].filter(Boolean).join('\n\n')
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, llms?: string, pricing?: string, dictionary?: object }>>,
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 sections = []
69
- const add = (title, body) => { if (body) sections.push({ title, text: body }) }
70
- add(t.llms, text(src.llms))
71
- add(t.pricing, text(src.pricing))
72
- add(t.dictionary, src.dictionary ? flattenDictionary(src.dictionary) : '')
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
- add(`${e.collection}${t.colon}${text(e.title)}`, entryText(e))
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
- if (sections.length === 0) throw new Error(`chat : contexte ${lang} vide`)
160
+ for (const [label, lines] of groups) sections.push({ title: label, text: lines.join('\n') })
78
161
  const part = {}
79
- if (text(src.business)) part.business = text(src.business)
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
- 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; }