From 67cb5a2da92af52b8d4e64fa18d719701c8631b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kjell=20Rune=20Mons=C3=B8?= Date: Thu, 1 Oct 2026 15:07:46 +0200 Subject: [PATCH] feat(chat-widget): add an opt-in nudge above the launcher Adds data-munin-nudge, data-munin-nudge-delay and data-munin-nudge-color. The nudge shows a teaser message with an inline input; sending opens the panel into a new conversation. It is suppressed once the panel has been opened or the session has messages, and snoozed for 7 days per channel after a dismissal or open. The launcher now follows data-munin-corners (square by default), the unread badge is larger, borderless and centered on the square corner, and opening the widget with no conversations goes straight to a new chat. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/widget-nudge.md | 14 ++ apps/chat-widget/src/config.test.ts | 60 ++++++++ apps/chat-widget/src/config.ts | 41 ++++++ apps/chat-widget/src/nudge.test.ts | 36 +++++ apps/chat-widget/src/nudge.ts | 30 ++++ apps/chat-widget/src/strings/cs.ts | 4 + apps/chat-widget/src/strings/da.ts | 4 + apps/chat-widget/src/strings/de.ts | 4 + apps/chat-widget/src/strings/en.ts | 4 + apps/chat-widget/src/strings/es.ts | 4 + apps/chat-widget/src/strings/et.ts | 4 + apps/chat-widget/src/strings/fi.ts | 4 + apps/chat-widget/src/strings/fr.ts | 4 + apps/chat-widget/src/strings/hu.ts | 4 + apps/chat-widget/src/strings/is.ts | 4 + apps/chat-widget/src/strings/it.ts | 4 + apps/chat-widget/src/strings/lt.ts | 4 + apps/chat-widget/src/strings/lv.ts | 4 + apps/chat-widget/src/strings/nb.ts | 4 + apps/chat-widget/src/strings/nl.ts | 4 + apps/chat-widget/src/strings/nn.ts | 4 + apps/chat-widget/src/strings/pl.ts | 4 + apps/chat-widget/src/strings/pt.ts | 4 + apps/chat-widget/src/strings/ro.ts | 4 + apps/chat-widget/src/strings/sk.ts | 4 + apps/chat-widget/src/strings/sv.ts | 4 + apps/chat-widget/src/strings/types.ts | 4 + apps/chat-widget/src/styles.ts | 113 ++++++++++++++- apps/chat-widget/src/ui.attachments.test.ts | 2 + apps/chat-widget/src/ui.test.ts | 131 +++++++++++++++++- apps/chat-widget/src/ui.ts | 116 +++++++++++++++- apps/chat-widget/src/widget.test.ts | 2 + apps/chat-widget/src/widget.ts | 45 ++++++ .../backend-core/docs-fixtures/skills.json | 2 +- .../modules/conv/skills/setup-chat-widget.md | 4 + .../src/guides/chat-widget/page.tsx | 24 ++++ 36 files changed, 697 insertions(+), 11 deletions(-) create mode 100644 .changeset/widget-nudge.md create mode 100644 apps/chat-widget/src/nudge.test.ts create mode 100644 apps/chat-widget/src/nudge.ts diff --git a/.changeset/widget-nudge.md b/.changeset/widget-nudge.md new file mode 100644 index 000000000..a91ca48fe --- /dev/null +++ b/.changeset/widget-nudge.md @@ -0,0 +1,14 @@ +--- +'@getmunin/chat-widget': minor +'@getmunin/backend-core': minor +'@getmunin/docs-pages': minor +--- + +Chat widget: an opt-in nudge above the closed launcher, plus launcher refinements. + +- `data-munin-nudge` shows a short teaser after a delay: an eyebrow (org name · now), a message bubble, a dismiss button and an inline input. Leave the value empty for a localized default (all 21 bundled languages). Sending from the input opens the panel straight into a new conversation with that message as the first turn. The launcher badge reads `1` while it shows; real unread messages take precedence. +- It never appears once the panel has been opened or the current session already has messages, and a dismissal or open snoozes it for 7 days per channel (stored in `localStorage`). +- `data-munin-nudge-delay` sets the delay in seconds (default 8, 0–3600). The bubble defaults to a light tint of the theme color; `data-munin-nudge-color` sets it explicitly, with the text flipping between ink and paper for contrast. +- The launcher now follows `data-munin-corners`: square (the default) gives a square launcher, rounded keeps the circle. On the square launcher the unread badge sits centered on the corner; the badge is slightly larger and no longer has a border. +- Opening the widget when the visitor has no past or current conversation goes straight to a new chat instead of the welcome screen. +- `skill://conv/setup-chat-widget` and the chat-widget docs guide document the new attributes and `data-munin-corners`. diff --git a/apps/chat-widget/src/config.test.ts b/apps/chat-widget/src/config.test.ts index 09c51feec..7aa6e2356 100644 --- a/apps/chat-widget/src/config.test.ts +++ b/apps/chat-widget/src/config.test.ts @@ -39,6 +39,8 @@ describe('parseConfig', () => { corners: 'square', colorScheme: 'auto', showHistory: true, + nudge: null, + nudgeDelayMs: 8000, }); expect(result.config.visitor).toBeUndefined(); expect(result.config.externalId).toBeUndefined(); @@ -519,3 +521,61 @@ describe('parseConfig', () => { expect(result.config.visitor?.name).toHaveLength(120); }); }); + +describe('parseConfig nudge', () => { + const required = { + 'data-munin-host': 'https://munin.example.com', + 'data-widget-key': 'mn_widget_abc', + 'data-channel-id': 'cnv_001', + }; + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + function parse(attrs: Record) { + const result = parseConfig(makeScript({ ...required, ...attrs })); + if (!result.ok) throw new Error('expected a valid config'); + return result; + } + + it('leaves the nudge off when the attribute is absent', () => { + expect(parse({}).config.nudge).toBeNull(); + }); + + it('uses the localized default for an empty or boolean-true value', () => { + expect(parse({ 'data-munin-nudge': '' }).config.nudge).toBe(''); + expect(parse({ 'data-munin-nudge': 'true' }).config.nudge).toBe(''); + }); + + it('treats an explicit false as off', () => { + expect(parse({ 'data-munin-nudge': 'false' }).config.nudge).toBeNull(); + }); + + it('keeps custom text, trimmed and capped at 280 chars', () => { + expect(parse({ 'data-munin-nudge': ' Need a hand? ' }).config.nudge).toBe('Need a hand?'); + expect(parse({ 'data-munin-nudge': 'x'.repeat(400) }).config.nudge).toHaveLength(280); + }); + + it('accepts a hex nudge color and warns on anything else', () => { + expect(parse({}).config.nudgeColor).toBeUndefined(); + expect(parse({ 'data-munin-nudge-color': '#FFE8CC' }).config.nudgeColor).toBe('#FFE8CC'); + const bad = parse({ 'data-munin-nudge-color': 'peach' }); + expect(bad.config.nudgeColor).toBeUndefined(); + expect(bad.warnings.map((w) => w.attr)).toContain('data-munin-nudge-color'); + }); + + it('reads the delay in seconds and defaults to 8', () => { + expect(parse({}).config.nudgeDelayMs).toBe(8000); + expect(parse({ 'data-munin-nudge-delay': '2.5' }).config.nudgeDelayMs).toBe(2500); + expect(parse({ 'data-munin-nudge-delay': '0' }).config.nudgeDelayMs).toBe(0); + }); + + it('warns and falls back on an out-of-range or non-numeric delay', () => { + for (const bad of ['-1', 'soon', '', '9999']) { + const result = parse({ 'data-munin-nudge-delay': bad }); + expect(result.config.nudgeDelayMs).toBe(8000); + expect(result.warnings.map((w) => w.attr)).toContain('data-munin-nudge-delay'); + } + }); +}); diff --git a/apps/chat-widget/src/config.ts b/apps/chat-widget/src/config.ts index c12fcc035..b200ff692 100644 --- a/apps/chat-widget/src/config.ts +++ b/apps/chat-widget/src/config.ts @@ -1,5 +1,7 @@ export const WIDGET_END_USER_BODY_MAX_CHARS = 1_000; export const WIDGET_END_USER_BODY_HTML_MAX_CHARS = 4_000; +export const NUDGE_TEXT_MAX_CHARS = 280; +export const NUDGE_DELAY_MAX_SECONDS = 3_600; const VALID_POSITIONS = ['bottom-right', 'bottom-left'] as const; type Position = (typeof VALID_POSITIONS)[number]; @@ -49,6 +51,9 @@ export interface WidgetConfig { corners: Corners; colorScheme: ColorScheme; showHistory: boolean; + nudge: string | null; + nudgeDelayMs: number; + nudgeColor?: string; visitor?: WidgetVisitor; cookieDomain?: string; } @@ -70,6 +75,7 @@ const DEFAULTS = { corners: 'square' as Corners, colorScheme: 'auto' as ColorScheme, showHistory: true, + nudgeDelaySeconds: 8, }; export function parseConfig(scriptEl: HTMLElement): ParseResult { @@ -121,6 +127,11 @@ export function parseConfig(scriptEl: HTMLElement): ParseResult { DEFAULTS.colorScheme; const showHistory = optBool(scriptEl, 'data-munin-show-history', warnings) ?? DEFAULTS.showHistory; + const nudge = parseNudge(scriptEl); + const nudgeColor = optColor(scriptEl, 'data-munin-nudge-color', warnings); + const nudgeDelayMs = + (optSeconds(scriptEl, 'data-munin-nudge-delay', NUDGE_DELAY_MAX_SECONDS, warnings) ?? + DEFAULTS.nudgeDelaySeconds) * 1000; const visitor = parseVisitor(scriptEl, warnings); const cookieDomain = optCookieDomain(scriptEl, warnings); @@ -153,6 +164,9 @@ export function parseConfig(scriptEl: HTMLElement): ParseResult { corners, colorScheme, showHistory, + nudge, + nudgeDelayMs, + nudgeColor, visitor, cookieDomain, }, @@ -244,6 +258,33 @@ function optBool(el: HTMLElement, attr: string, warnings: ParseError[]): boolean return undefined; } +function parseNudge(el: HTMLElement): string | null { + const v = el.getAttribute('data-munin-nudge'); + if (v === null) return null; + const t = v.trim(); + const lower = t.toLowerCase(); + if (lower === 'false' || lower === '0' || lower === 'no') return null; + if (t === '' || lower === 'true' || lower === '1' || lower === 'yes') return ''; + return t.slice(0, NUDGE_TEXT_MAX_CHARS); +} + +function optSeconds( + el: HTMLElement, + attr: string, + max: number, + warnings: ParseError[], +): number | undefined { + const v = el.getAttribute(attr); + if (v === null) return undefined; + const t = v.trim(); + const n = Number(t); + if (t === '' || !Number.isFinite(n) || n < 0 || n > max) { + warnings.push({ attr, message: `${attr} must be a number of seconds between 0 and ${max}` }); + return undefined; + } + return n; +} + function parseVisitor(el: HTMLElement, warnings: ParseError[]): WidgetVisitor | undefined { const name = el.getAttribute('data-munin-visitor-name')?.trim(); const emailRaw = el.getAttribute('data-munin-visitor-email')?.trim(); diff --git a/apps/chat-widget/src/nudge.test.ts b/apps/chat-widget/src/nudge.test.ts new file mode 100644 index 000000000..04344b28d --- /dev/null +++ b/apps/chat-widget/src/nudge.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { isNudgeSnoozed, NUDGE_SNOOZE_MS, snoozeNudge } from './nudge.ts'; + +describe('nudge snooze', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('is not snoozed for a channel that was never dismissed', () => { + expect(isNudgeSnoozed('cch_never')).toBe(false); + }); + + it('stays snoozed inside the window and expires after it', () => { + const t0 = 1_700_000_000_000; + snoozeNudge('cch_window', t0); + expect(isNudgeSnoozed('cch_window', t0 + NUDGE_SNOOZE_MS - 1)).toBe(true); + expect(isNudgeSnoozed('cch_window', t0 + NUDGE_SNOOZE_MS)).toBe(false); + }); + + it('persists the snooze to localStorage per channel', () => { + snoozeNudge('cch_persist', 1234); + expect(localStorage.getItem('munin-widget-nudge-snoozed:cch_persist')).toBe('1234'); + expect(isNudgeSnoozed('cch_other', 1234)).toBe(false); + }); + + it('reads a snooze written by an earlier page load', () => { + const now = Date.now(); + localStorage.setItem('munin-widget-nudge-snoozed:cch_earlier', String(now - 1000)); + expect(isNudgeSnoozed('cch_earlier', now)).toBe(true); + }); + + it('ignores a corrupt stored value', () => { + localStorage.setItem('munin-widget-nudge-snoozed:cch_corrupt', 'garbage'); + expect(isNudgeSnoozed('cch_corrupt')).toBe(false); + }); +}); diff --git a/apps/chat-widget/src/nudge.ts b/apps/chat-widget/src/nudge.ts new file mode 100644 index 000000000..fb9fdb15a --- /dev/null +++ b/apps/chat-widget/src/nudge.ts @@ -0,0 +1,30 @@ +const SNOOZE_PREFIX = 'munin-widget-nudge-snoozed:'; +export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; + +const memory = new Map(); + +export function isNudgeSnoozed(channelId: string, now: number = Date.now()): boolean { + const at = memory.get(channelId) ?? readStoredAt(channelId); + if (at === null) return false; + return now - at < NUDGE_SNOOZE_MS; +} + +export function snoozeNudge(channelId: string, now: number = Date.now()): void { + memory.set(channelId, now); + try { + localStorage.setItem(SNOOZE_PREFIX + channelId, String(now)); + } catch { + return; + } +} + +function readStoredAt(channelId: string): number | null { + try { + const raw = localStorage.getItem(SNOOZE_PREFIX + channelId); + if (raw === null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; + } catch { + return null; + } +} diff --git a/apps/chat-widget/src/strings/cs.ts b/apps/chat-widget/src/strings/cs.ts index 10a13652c..a058d21cb 100644 --- a/apps/chat-widget/src/strings/cs.ts +++ b/apps/chat-widget/src/strings/cs.ts @@ -68,6 +68,10 @@ const cs: Strings = { attachTooLarge: 'Obrázek musí mít méně než {n} MB.', attachmentUnavailable: 'Obrázek už není dostupný', lightboxCloseAriaLabel: 'Zavřít obrázek', + nudgeDefault: 'Dobrý den. Máte dotaz? Napište ho sem a hned odpovíme.', + nudgePlaceholder: 'Zeptejte se…', + nudgeAuthor: 'Zákaznická podpora', + nudgeDismissAriaLabel: 'Skrýt zprávu', }; export default cs; diff --git a/apps/chat-widget/src/strings/da.ts b/apps/chat-widget/src/strings/da.ts index 7bfacd9f0..ea2f1de54 100644 --- a/apps/chat-widget/src/strings/da.ts +++ b/apps/chat-widget/src/strings/da.ts @@ -68,6 +68,10 @@ const da: Strings = { attachTooLarge: 'Billeder skal være under {n} MB.', attachmentUnavailable: 'Billedet er ikke længere tilgængeligt', lightboxCloseAriaLabel: 'Luk billedet', + nudgeDefault: 'Hej. Er der noget, du vil vide? Skriv her, så svarer vi med det samme.', + nudgePlaceholder: 'Stil et spørgsmål…', + nudgeAuthor: 'Kundesupport', + nudgeDismissAriaLabel: 'Skjul beskeden', }; export default da; diff --git a/apps/chat-widget/src/strings/de.ts b/apps/chat-widget/src/strings/de.ts index 71505940a..8ffaee2b3 100644 --- a/apps/chat-widget/src/strings/de.ts +++ b/apps/chat-widget/src/strings/de.ts @@ -68,6 +68,10 @@ const de: Strings = { attachTooLarge: 'Bilder müssen kleiner als {n} MB sein.', attachmentUnavailable: 'Bild nicht mehr verfügbar', lightboxCloseAriaLabel: 'Bild schließen', + nudgeDefault: 'Hallo. Haben Sie eine Frage? Schreiben Sie hier, wir antworten sofort.', + nudgePlaceholder: 'Stellen Sie eine Frage…', + nudgeAuthor: 'Kundensupport', + nudgeDismissAriaLabel: 'Nachricht ausblenden', }; export default de; diff --git a/apps/chat-widget/src/strings/en.ts b/apps/chat-widget/src/strings/en.ts index 06ee1abcb..3c46e1d09 100644 --- a/apps/chat-widget/src/strings/en.ts +++ b/apps/chat-widget/src/strings/en.ts @@ -68,6 +68,10 @@ const en: Strings = { attachTooLarge: 'Images have to be under {n} MB.', attachmentUnavailable: 'Image no longer available', lightboxCloseAriaLabel: 'Close image', + nudgeDefault: "Hi. Got a question? Type it here and we'll answer right away.", + nudgePlaceholder: "Ask a question…", + nudgeAuthor: "Support", + nudgeDismissAriaLabel: "Dismiss message", }; export default en; diff --git a/apps/chat-widget/src/strings/es.ts b/apps/chat-widget/src/strings/es.ts index ed86e1a7f..4753fcd1a 100644 --- a/apps/chat-widget/src/strings/es.ts +++ b/apps/chat-widget/src/strings/es.ts @@ -68,6 +68,10 @@ const es: Strings = { attachTooLarge: 'Las imágenes deben pesar menos de {n} MB.', attachmentUnavailable: 'La imagen ya no está disponible', lightboxCloseAriaLabel: 'Cerrar la imagen', + nudgeDefault: 'Hola. ¿Tienes alguna pregunta? Escríbela aquí y te respondemos al momento.', + nudgePlaceholder: 'Haz una pregunta…', + nudgeAuthor: 'Atención al cliente', + nudgeDismissAriaLabel: 'Ocultar el mensaje', }; export default es; diff --git a/apps/chat-widget/src/strings/et.ts b/apps/chat-widget/src/strings/et.ts index 7099318c5..92157d199 100644 --- a/apps/chat-widget/src/strings/et.ts +++ b/apps/chat-widget/src/strings/et.ts @@ -68,6 +68,10 @@ const et: Strings = { attachTooLarge: 'Pilt peab olema alla {n} MB.', attachmentUnavailable: 'Pilt ei ole enam saadaval', lightboxCloseAriaLabel: 'Sulge pilt', + nudgeDefault: 'Tere. Kas sul on küsimus? Kirjuta siia ja vastame kohe.', + nudgePlaceholder: 'Esita küsimus…', + nudgeAuthor: 'Klienditugi', + nudgeDismissAriaLabel: 'Peida sõnum', }; export default et; diff --git a/apps/chat-widget/src/strings/fi.ts b/apps/chat-widget/src/strings/fi.ts index f69c62120..ca294a99f 100644 --- a/apps/chat-widget/src/strings/fi.ts +++ b/apps/chat-widget/src/strings/fi.ts @@ -68,6 +68,10 @@ const fi: Strings = { attachTooLarge: 'Kuvan koko saa olla enintään {n} MB.', attachmentUnavailable: 'Kuva ei ole enää saatavilla', lightboxCloseAriaLabel: 'Sulje kuva', + nudgeDefault: 'Hei. Onko sinulla kysyttävää? Kirjoita tähän, niin vastaamme heti.', + nudgePlaceholder: 'Kysy jotain…', + nudgeAuthor: 'Asiakastuki', + nudgeDismissAriaLabel: 'Piilota viesti', }; export default fi; diff --git a/apps/chat-widget/src/strings/fr.ts b/apps/chat-widget/src/strings/fr.ts index 11e9545f1..31e8efe17 100644 --- a/apps/chat-widget/src/strings/fr.ts +++ b/apps/chat-widget/src/strings/fr.ts @@ -68,6 +68,10 @@ const fr: Strings = { attachTooLarge: 'Les images doivent peser moins de {n} Mo.', attachmentUnavailable: 'Image plus disponible', lightboxCloseAriaLabel: 'Fermer l’image', + nudgeDefault: 'Bonjour. Une question ? Écrivez-la ici, nous répondons tout de suite.', + nudgePlaceholder: 'Posez une question…', + nudgeAuthor: 'Service client', + nudgeDismissAriaLabel: 'Masquer le message', }; export default fr; diff --git a/apps/chat-widget/src/strings/hu.ts b/apps/chat-widget/src/strings/hu.ts index eab3f4155..3c5b34c49 100644 --- a/apps/chat-widget/src/strings/hu.ts +++ b/apps/chat-widget/src/strings/hu.ts @@ -68,6 +68,10 @@ const hu: Strings = { attachTooLarge: 'A kép legfeljebb {n} MB lehet.', attachmentUnavailable: 'A kép már nem elérhető', lightboxCloseAriaLabel: 'Kép bezárása', + nudgeDefault: 'Szia. Kérdésed van? Írd ide, és azonnal válaszolunk.', + nudgePlaceholder: 'Tegyél fel egy kérdést…', + nudgeAuthor: 'Ügyfélszolgálat', + nudgeDismissAriaLabel: 'Üzenet elrejtése', }; export default hu; diff --git a/apps/chat-widget/src/strings/is.ts b/apps/chat-widget/src/strings/is.ts index 887764680..6dd282294 100644 --- a/apps/chat-widget/src/strings/is.ts +++ b/apps/chat-widget/src/strings/is.ts @@ -68,6 +68,10 @@ const is: Strings = { attachTooLarge: 'Myndir verða að vera undir {n} MB.', attachmentUnavailable: 'Myndin er ekki lengur tiltæk', lightboxCloseAriaLabel: 'Loka mynd', + nudgeDefault: 'Hæ. Ertu með spurningu? Skrifaðu hér og við svörum strax.', + nudgePlaceholder: 'Spyrðu spurningar…', + nudgeAuthor: 'Þjónustuver', + nudgeDismissAriaLabel: 'Fela skilaboð', }; export default is; diff --git a/apps/chat-widget/src/strings/it.ts b/apps/chat-widget/src/strings/it.ts index 7b4da2ef0..61617548b 100644 --- a/apps/chat-widget/src/strings/it.ts +++ b/apps/chat-widget/src/strings/it.ts @@ -68,6 +68,10 @@ const it: Strings = { attachTooLarge: 'Le immagini devono pesare meno di {n} MB.', attachmentUnavailable: 'Immagine non più disponibile', lightboxCloseAriaLabel: 'Chiudi l’immagine', + nudgeDefault: 'Ciao. Hai una domanda? Scrivila qui e ti rispondiamo subito.', + nudgePlaceholder: 'Fai una domanda…', + nudgeAuthor: 'Assistenza clienti', + nudgeDismissAriaLabel: 'Nascondi il messaggio', }; export default it; diff --git a/apps/chat-widget/src/strings/lt.ts b/apps/chat-widget/src/strings/lt.ts index e6b6be848..2491547e5 100644 --- a/apps/chat-widget/src/strings/lt.ts +++ b/apps/chat-widget/src/strings/lt.ts @@ -68,6 +68,10 @@ const lt: Strings = { attachTooLarge: 'Vaizdas turi būti mažesnis nei {n} MB.', attachmentUnavailable: 'Vaizdas nebepasiekiamas', lightboxCloseAriaLabel: 'Uždaryti vaizdą', + nudgeDefault: 'Sveiki. Turite klausimą? Parašykite čia ir iškart atsakysime.', + nudgePlaceholder: 'Užduokite klausimą…', + nudgeAuthor: 'Klientų aptarnavimas', + nudgeDismissAriaLabel: 'Paslėpti žinutę', }; export default lt; diff --git a/apps/chat-widget/src/strings/lv.ts b/apps/chat-widget/src/strings/lv.ts index 5536e0331..639862f31 100644 --- a/apps/chat-widget/src/strings/lv.ts +++ b/apps/chat-widget/src/strings/lv.ts @@ -68,6 +68,10 @@ const lv: Strings = { attachTooLarge: 'Attēlam jābūt mazākam par {n} MB.', attachmentUnavailable: 'Attēls vairs nav pieejams', lightboxCloseAriaLabel: 'Aizvērt attēlu', + nudgeDefault: 'Sveiki. Vai jums ir jautājums? Rakstiet šeit, un mēs atbildēsim uzreiz.', + nudgePlaceholder: 'Uzdodiet jautājumu…', + nudgeAuthor: 'Klientu atbalsts', + nudgeDismissAriaLabel: 'Paslēpt ziņu', }; export default lv; diff --git a/apps/chat-widget/src/strings/nb.ts b/apps/chat-widget/src/strings/nb.ts index 938152b1e..f3ab82361 100644 --- a/apps/chat-widget/src/strings/nb.ts +++ b/apps/chat-widget/src/strings/nb.ts @@ -68,6 +68,10 @@ const nb: Strings = { attachTooLarge: 'Bildet må være under {n} MB.', attachmentUnavailable: 'Bildet er ikke tilgjengelig lenger', lightboxCloseAriaLabel: 'Lukk bildet', + nudgeDefault: 'Hei. Lurer du på noe? Skriv her, så svarer vi med en gang.', + nudgePlaceholder: 'Still et spørsmål…', + nudgeAuthor: 'Kundesupport', + nudgeDismissAriaLabel: 'Skjul meldingen', }; export default nb; diff --git a/apps/chat-widget/src/strings/nl.ts b/apps/chat-widget/src/strings/nl.ts index 0c24ab519..7912188a8 100644 --- a/apps/chat-widget/src/strings/nl.ts +++ b/apps/chat-widget/src/strings/nl.ts @@ -68,6 +68,10 @@ const nl: Strings = { attachTooLarge: 'Afbeeldingen moeten kleiner zijn dan {n} MB.', attachmentUnavailable: 'Afbeelding niet meer beschikbaar', lightboxCloseAriaLabel: 'Afbeelding sluiten', + nudgeDefault: 'Hoi. Heb je een vraag? Typ hem hier en we antwoorden meteen.', + nudgePlaceholder: 'Stel een vraag…', + nudgeAuthor: 'Klantenservice', + nudgeDismissAriaLabel: 'Bericht verbergen', }; export default nl; diff --git a/apps/chat-widget/src/strings/nn.ts b/apps/chat-widget/src/strings/nn.ts index a92f62ae7..5b094edb5 100644 --- a/apps/chat-widget/src/strings/nn.ts +++ b/apps/chat-widget/src/strings/nn.ts @@ -68,6 +68,10 @@ const nn: Strings = { attachTooLarge: 'Biletet må vere under {n} MB.', attachmentUnavailable: 'Biletet er ikkje tilgjengeleg lenger', lightboxCloseAriaLabel: 'Lukk biletet', + nudgeDefault: 'Hei. Lurer du på noko? Skriv her, så svarar vi med ein gong.', + nudgePlaceholder: 'Still eit spørsmål…', + nudgeAuthor: 'Kundestøtte', + nudgeDismissAriaLabel: 'Skjul meldinga', }; export default nn; diff --git a/apps/chat-widget/src/strings/pl.ts b/apps/chat-widget/src/strings/pl.ts index aba62ecb5..9a6278be6 100644 --- a/apps/chat-widget/src/strings/pl.ts +++ b/apps/chat-widget/src/strings/pl.ts @@ -68,6 +68,10 @@ const pl: Strings = { attachTooLarge: 'Obraz musi mieć mniej niż {n} MB.', attachmentUnavailable: 'Obraz jest już niedostępny', lightboxCloseAriaLabel: 'Zamknij obraz', + nudgeDefault: 'Cześć. Masz pytanie? Napisz tutaj, odpowiemy od razu.', + nudgePlaceholder: 'Zadaj pytanie…', + nudgeAuthor: 'Obsługa klienta', + nudgeDismissAriaLabel: 'Ukryj wiadomość', }; export default pl; diff --git a/apps/chat-widget/src/strings/pt.ts b/apps/chat-widget/src/strings/pt.ts index a0fb12ae4..df8201bc3 100644 --- a/apps/chat-widget/src/strings/pt.ts +++ b/apps/chat-widget/src/strings/pt.ts @@ -68,6 +68,10 @@ const pt: Strings = { attachTooLarge: 'As imagens têm de ter menos de {n} MB.', attachmentUnavailable: 'Imagem já não disponível', lightboxCloseAriaLabel: 'Fechar a imagem', + nudgeDefault: 'Olá. Tem alguma dúvida? Escreva aqui e respondemos de imediato.', + nudgePlaceholder: 'Faça uma pergunta…', + nudgeAuthor: 'Apoio ao cliente', + nudgeDismissAriaLabel: 'Ocultar a mensagem', }; export default pt; diff --git a/apps/chat-widget/src/strings/ro.ts b/apps/chat-widget/src/strings/ro.ts index 913591c01..c9b643c5a 100644 --- a/apps/chat-widget/src/strings/ro.ts +++ b/apps/chat-widget/src/strings/ro.ts @@ -68,6 +68,10 @@ const ro: Strings = { attachTooLarge: 'Imaginile trebuie să aibă mai puțin de {n} MB.', attachmentUnavailable: 'Imaginea nu mai este disponibilă', lightboxCloseAriaLabel: 'Închide imaginea', + nudgeDefault: 'Bună. Ai o întrebare? Scrie aici și îți răspundem imediat.', + nudgePlaceholder: 'Pune o întrebare…', + nudgeAuthor: 'Asistență clienți', + nudgeDismissAriaLabel: 'Ascunde mesajul', }; export default ro; diff --git a/apps/chat-widget/src/strings/sk.ts b/apps/chat-widget/src/strings/sk.ts index 43b65a911..919f44a0c 100644 --- a/apps/chat-widget/src/strings/sk.ts +++ b/apps/chat-widget/src/strings/sk.ts @@ -68,6 +68,10 @@ const sk: Strings = { attachTooLarge: 'Obrázok musí mať menej ako {n} MB.', attachmentUnavailable: 'Obrázok už nie je dostupný', lightboxCloseAriaLabel: 'Zavrieť obrázok', + nudgeDefault: 'Dobrý deň. Máte otázku? Napíšte ju sem a hneď odpovieme.', + nudgePlaceholder: 'Opýtajte sa…', + nudgeAuthor: 'Zákaznícka podpora', + nudgeDismissAriaLabel: 'Skryť správu', }; export default sk; diff --git a/apps/chat-widget/src/strings/sv.ts b/apps/chat-widget/src/strings/sv.ts index 39427b2c6..6462a153b 100644 --- a/apps/chat-widget/src/strings/sv.ts +++ b/apps/chat-widget/src/strings/sv.ts @@ -68,6 +68,10 @@ const sv: Strings = { attachTooLarge: 'Bilder måste vara under {n} MB.', attachmentUnavailable: 'Bilden är inte längre tillgänglig', lightboxCloseAriaLabel: 'Stäng bilden', + nudgeDefault: 'Hej. Undrar du något? Skriv här så svarar vi direkt.', + nudgePlaceholder: 'Ställ en fråga…', + nudgeAuthor: 'Kundsupport', + nudgeDismissAriaLabel: 'Dölj meddelandet', }; export default sv; diff --git a/apps/chat-widget/src/strings/types.ts b/apps/chat-widget/src/strings/types.ts index a20a99417..cc29fdd78 100644 --- a/apps/chat-widget/src/strings/types.ts +++ b/apps/chat-widget/src/strings/types.ts @@ -77,6 +77,10 @@ export interface Strings { attachTooLarge: string; attachmentUnavailable: string; lightboxCloseAriaLabel: string; + nudgeDefault: string; + nudgePlaceholder: string; + nudgeAuthor: string; + nudgeDismissAriaLabel: string; } const PLURAL_KEYS: readonly (keyof PluralValue)[] = ['zero', 'one', 'two', 'few', 'many', 'other']; diff --git a/apps/chat-widget/src/styles.ts b/apps/chat-widget/src/styles.ts index e99ec0b6d..52c0841c4 100644 --- a/apps/chat-widget/src/styles.ts +++ b/apps/chat-widget/src/styles.ts @@ -18,6 +18,7 @@ const DARK_VARS = String.raw` --munin-theme-edge: var(--munin-theme-edge-dark, var(--munin-theme)); --munin-verdigris: #62C39C; --munin-verdigris-tint: #1B382A; + --munin-nudge-mix: 24%; --munin-agent-tint: #1E252C; --munin-self-tint: #272C33; --munin-field-border: rgba(255, 255, 255, 0.34); @@ -141,6 +142,7 @@ button { inset 0 0 0 1px rgba(255, 255, 255, 0.06); transition: transform 160ms cubic-bezier(.2,.7,.2,1), box-shadow 160ms; } +.root[data-corners='square'] .launcher { border-radius: var(--munin-r-surface); } .launcher:hover { transform: translateY(-2px) scale(1.03); } .launcher:active { transform: translateY(0) scale(.98); } .launcher:focus-visible { @@ -153,23 +155,119 @@ button { position: absolute; top: -2px; right: -2px; - min-width: 18px; - height: 18px; - padding: 0 4px; + min-width: 22px; + height: 22px; + padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: var(--munin-badge, var(--munin-theme-fill, var(--munin-theme))); color: var(--munin-badge-fg, var(--munin-theme-fg)); font-family: var(--munin-mono); - font-size: 11px; + font-size: 12px; font-weight: 600; line-height: 1; display: inline-flex; align-items: center; justify-content: center; - border: 1px solid var(--munin-bone); pointer-events: none; } +.root[data-corners='square'] .launcher-badge { top: -9px; right: -9px; } + +.nudge { + position: absolute; + bottom: 72px; + width: 300px; + max-width: calc(100vw - 48px); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 10px; + opacity: 0; + transform: translateY(8px); + transition: + transform 220ms cubic-bezier(.2,.7,.2,1), + opacity 180ms cubic-bezier(.2,.7,.2,1); +} +.nudge.open { opacity: 1; transform: translateY(0); } +.root[data-position='bottom-right'] .nudge { right: 0; } +.root[data-position='bottom-left'] .nudge { left: 0; } +.nudge-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; +} +.nudge-eyebrow { + padding-left: 2px; + font-family: var(--munin-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--munin-theme-edge); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.nudge-close { + flex-shrink: 0; + width: 24px; + height: 24px; + border: 1px solid var(--munin-rule); + border-radius: var(--munin-r-control); + background: var(--munin-paper); + color: var(--munin-ink-mute); + display: inline-flex; + align-items: center; + justify-content: center; +} +.nudge-close:hover { color: var(--munin-ink); border-color: var(--munin-ink); } +.nudge-close svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; } +.nudge-message { + display: block; + width: 100%; + text-align: left; + padding: 12px 14px; + background: var(--munin-nudge-bg, color-mix(in srgb, var(--munin-theme) var(--munin-nudge-mix, 12%), var(--munin-paper))); + border-radius: 14px; + color: var(--munin-nudge-fg, var(--munin-ink)); + font-size: 14.5px; + line-height: 1.45; + word-wrap: break-word; +} +.nudge-form { + display: flex; + align-items: stretch; + border: 1px solid var(--munin-field-border); + border-radius: var(--munin-r-surface); + background: var(--munin-paper); + overflow: hidden; +} +.nudge-form:focus-within { + outline: 2px solid var(--munin-theme-edge); + outline-offset: -2px; +} +.nudge-input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + padding: 10px 12px; + font: inherit; + font-size: 13.5px; + color: var(--munin-ink); + outline: none; +} +.nudge-input::placeholder { color: var(--munin-ink-mute); } +.nudge-send { + flex-shrink: 0; + width: 44px; + border-left: 1px solid var(--munin-rule); + color: var(--munin-send); + font-size: 18px; + line-height: 1; +} +.nudge-send:hover { background: var(--munin-paper-deep); } /* ─── Panel ──────────────────────────────────────────── */ .panel { @@ -982,6 +1080,7 @@ button { .root { bottom: 16px; } .root[data-position='bottom-right'] { right: 16px; } .root[data-position='bottom-left'] { left: 16px; } + .nudge { max-width: calc(100vw - 32px); } .root[data-size] .panel { position: fixed; @@ -1002,12 +1101,14 @@ button { @media (hover: none) and (pointer: coarse) { .composer textarea, - .card-form input { font-size: 16px; } + .card-form input, + .nudge-input { font-size: 16px; } } @media (prefers-reduced-motion: reduce) { .launcher { transition: none; } .panel { transition: none; } + .nudge { transition: none; } .bubble.typing span { animation: none; opacity: 0.6; } } `; diff --git a/apps/chat-widget/src/ui.attachments.test.ts b/apps/chat-widget/src/ui.attachments.test.ts index cc1c5a242..cc1eeff8d 100644 --- a/apps/chat-widget/src/ui.attachments.test.ts +++ b/apps/chat-widget/src/ui.attachments.test.ts @@ -19,6 +19,8 @@ const baseConfig: WidgetConfig = { corners: 'square', colorScheme: 'auto', showHistory: true, + nudge: null, + nudgeDelayMs: 8000, }; let controller: UiController | null = null; diff --git a/apps/chat-widget/src/ui.test.ts b/apps/chat-widget/src/ui.test.ts index 5b70e8e7e..15d31f10e 100644 --- a/apps/chat-widget/src/ui.test.ts +++ b/apps/chat-widget/src/ui.test.ts @@ -19,6 +19,8 @@ const baseConfig: WidgetConfig = { corners: 'square', colorScheme: 'auto', showHistory: true, + nudge: null, + nudgeDelayMs: 8000, }; let controller: UiController | null = null; @@ -69,15 +71,42 @@ describe('ui: mount + lifecycle', () => { expect(($('.panel')).hidden).toBe(true); }); - it('opens the panel on launcher click, lands on the welcome screen', () => { + it('opens straight into a new chat on launcher click when the visitor has no conversations', () => { const onOpen = vi.fn(); controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {}, onOpen }); ($('.launcher')).click(); expect(($('.panel')).hidden).toBe(false); expect(($('.launcher')).hidden).toBe(true); + expect(($('.welcome')).hidden).toBe(true); + expect(($('.chat')).hidden).toBe(false); + expect($('.chat-title').textContent).toBe(strings.newConversation); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('lands on the welcome screen when the visitor has past conversations', () => { + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + controller.setPastConversations([ + { + id: 'ccv_a', + sessionId: 'sid_a', + title: 'Refund question', + preview: 'Thanks.', + status: 'closed', + handedOver: false, + lastMessageAt: new Date().toISOString(), + }, + ]); + ($('.launcher')).click(); expect(($('.welcome')).hidden).toBe(false); expect(($('.chat')).hidden).toBe(true); - expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('lands on the welcome screen when the current session already has messages', () => { + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + controller.addMessages([msg({ id: 'm1', role: 'agent', body: 'Hello' })]); + controller.setView('welcome'); + ($('.launcher')).click(); + expect(($('.welcome')).hidden).toBe(false); }); it('renders the configured greeting and eyebrow on the welcome screen', () => { @@ -195,6 +224,7 @@ describe('ui: welcome → chat transitions', () => { }); it('setView swaps the visible screen', () => { + controller!.setView('welcome'); expect(($('.welcome')).hidden).toBe(false); expect(($('.chat')).hidden).toBe(true); controller!.setView('chat'); @@ -996,3 +1026,100 @@ describe('ui: powered-by credit links', () => { } }); }); + +describe('ui: nudge', () => { + it('shows the nudge text with the org eyebrow and a badge of 1', () => { + controller = mount({ ...baseConfig, title: 'Acme' }, strings, { + onSend: () => {}, + onTypingIntent: () => {}, + }); + expect(($('.nudge')).hidden).toBe(true); + controller.showNudge('Got a question?'); + expect(($('.nudge')).hidden).toBe(false); + expect($('.nudge-text').textContent).toBe('Got a question?'); + expect($('.nudge-eyebrow').textContent).toBe(`Acme · ${strings.timeNow}`); + expect($('.launcher-badge').textContent).toBe('1'); + expect(controller.isNudgeVisible()).toBe(true); + }); + + it('paints an explicit nudge color with a contrasting text color', () => { + controller = mount({ ...baseConfig, nudgeColor: '#0F1419' }, strings, { + onSend: () => {}, + onTypingIntent: () => {}, + }); + const root = $('.root'); + expect(root.style.getPropertyValue('--munin-nudge-bg')).toBe('#0F1419'); + expect(root.style.getPropertyValue('--munin-nudge-fg')).not.toBe(''); + }); + + it('leaves the nudge on the theme tint when no color is set', () => { + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + expect($('.root').style.getPropertyValue('--munin-nudge-bg')).toBe(''); + }); + + it('lets real unread messages win over the nudge badge', () => { + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + controller.showNudge('Hi'); + controller.setLauncherUnread(3); + expect($('.launcher-badge').textContent).toBe('3'); + controller.setLauncherUnread(0); + expect($('.launcher-badge').textContent).toBe('1'); + }); + + it('dismisses on the close button, clears the badge and reports it', () => { + const onNudgeDismiss = vi.fn(); + controller = mount(baseConfig, strings, { + onSend: () => {}, + onTypingIntent: () => {}, + onNudgeDismiss, + }); + controller.showNudge('Hi'); + ($('.nudge-close')).click(); + expect(($('.nudge')).hidden).toBe(true); + expect(($('.launcher-badge')).hidden).toBe(true); + expect(onNudgeDismiss).toHaveBeenCalledTimes(1); + }); + + it('hands the typed text to onNudgeSend and hides the nudge', () => { + const onNudgeSend = vi.fn(); + controller = mount(baseConfig, strings, { + onSend: () => {}, + onTypingIntent: () => {}, + onNudgeSend, + }); + controller.showNudge('Hi'); + const input = $('.nudge-input'); + input.value = ' Where is my order? '; + $('.nudge-form').dispatchEvent(new Event('submit', { cancelable: true })); + expect(onNudgeSend).toHaveBeenCalledWith('Where is my order?'); + expect(controller.isNudgeVisible()).toBe(false); + }); + + it('ignores a blank submit', () => { + const onNudgeSend = vi.fn(); + controller = mount(baseConfig, strings, { + onSend: () => {}, + onTypingIntent: () => {}, + onNudgeSend, + }); + controller.showNudge('Hi'); + $('.nudge-form').dispatchEvent(new Event('submit', { cancelable: true })); + expect(onNudgeSend).not.toHaveBeenCalled(); + expect(controller.isNudgeVisible()).toBe(true); + }); + + it('opens the panel when the message is clicked and hides the nudge', () => { + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + controller.showNudge('Hi'); + ($('.nudge-message')).click(); + expect(controller.isOpen()).toBe(true); + expect(controller.isNudgeVisible()).toBe(false); + }); + + it('never shows over an open panel', () => { + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + controller.open(); + controller.showNudge('Hi'); + expect(controller.isNudgeVisible()).toBe(false); + }); +}); diff --git a/apps/chat-widget/src/ui.ts b/apps/chat-widget/src/ui.ts index 67f9d248b..163f52ac5 100644 --- a/apps/chat-widget/src/ui.ts +++ b/apps/chat-widget/src/ui.ts @@ -42,6 +42,8 @@ export interface UiHooks { onVoiceStart?: () => void; onVoiceEnd?: () => void; onVoiceMuteToggle?: (muted: boolean) => void; + onNudgeSend?: (text: string) => void; + onNudgeDismiss?: () => void; } export type VoiceUiState = 'idle' | 'connecting' | 'listening' | 'speaking' | 'ended' | 'error'; @@ -57,6 +59,9 @@ export interface UiController { setPastConversations(convs: ConversationSummary[]): void; setConversation(envelope: ConversationEnvelope | null): void; setLauncherUnread(count: number): void; + showNudge(text: string): void; + hideNudge(): void; + isNudgeVisible(): boolean; showEmailCard(): void; setEmailSaved(email: string): void; setView(view: 'welcome' | 'chat'): void; @@ -126,6 +131,10 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U } else if (config.launcherIconColor) { root.style.setProperty('--munin-launcher-fg', config.launcherIconColor); } + if (config.nudgeColor) { + root.style.setProperty('--munin-nudge-bg', config.nudgeColor); + root.style.setProperty('--munin-nudge-fg', readableOn(config.nudgeColor)); + } if (config.headerColor) { root.style.setProperty('--munin-header', config.headerColor); root.style.setProperty('--munin-header-fg', readableOn(config.headerColor)); @@ -134,7 +143,8 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U const { btn: launcher, badge: launcherBadge } = renderLauncher(strings); const panel = renderPanel(config, strings); - root.append(launcher, panel.el); + const nudge = renderNudge(config, strings); + root.append(nudge.el, launcher, panel.el); panel.el.hidden = true; let view: 'welcome' | 'chat' = 'welcome'; @@ -165,6 +175,19 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U : null; launcher.addEventListener('click', () => open()); + nudge.messageEl.addEventListener('click', () => open()); + nudge.closeBtn.addEventListener('click', () => { + hideNudge(); + hooks.onNudgeDismiss?.(); + }); + nudge.form.addEventListener('submit', (e) => { + e.preventDefault(); + const text = nudge.input.value.trim(); + if (text.length === 0) return; + nudge.input.value = ''; + hideNudge(); + hooks.onNudgeSend?.(text); + }); panel.closeBtn.addEventListener('click', () => close()); panel.backBtn.addEventListener('click', () => { hooks.onBackToWelcome?.(); @@ -352,7 +375,16 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U window.scrollTo(0, scrollY); } + function hasAnyConversation(): boolean { + return pastConvs.length > 0 || seenIds.size > 0 || conversationEnvelope !== null; + } + function open(): void { + hideNudge(); + if (view === 'welcome' && !hasAnyConversation()) { + setChatKind('new'); + setView('chat'); + } panelOpen = true; panel.el.hidden = false; lockBodyScroll(); @@ -830,7 +862,11 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U setPastConversations([]); paintChatHead(); - function setLauncherUnread(count: number): void { + let unreadCount = 0; + let nudgeVisible = false; + + function paintLauncherBadge(): void { + const count = unreadCount > 0 ? unreadCount : nudgeVisible ? 1 : 0; if (count <= 0) { launcherBadge.hidden = true; launcherBadge.textContent = ''; @@ -840,6 +876,34 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U launcherBadge.textContent = count > 9 ? '9+' : String(count); } + function setLauncherUnread(count: number): void { + unreadCount = count; + paintLauncherBadge(); + } + + function showNudge(text: string): void { + if (panelOpen) return; + nudge.textEl.textContent = text; + nudgeVisible = true; + nudge.el.hidden = false; + requestAnimationFrame(() => { + if (nudgeVisible) nudge.el.classList.add('open'); + }); + paintLauncherBadge(); + } + + function hideNudge(): void { + if (!nudgeVisible) return; + nudgeVisible = false; + nudge.el.classList.remove('open'); + nudge.el.hidden = true; + paintLauncherBadge(); + } + + function isNudgeVisible(): boolean { + return nudgeVisible; + } + return { addMessages, setAgentTyping, @@ -849,6 +913,9 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U setPastConversations, setConversation, setLauncherUnread, + showNudge, + hideNudge, + isNudgeVisible, showEmailCard, setEmailSaved, setView, @@ -1135,6 +1202,51 @@ function renderLauncher(strings: Strings): { btn: HTMLButtonElement; badge: HTML return { btn, badge }; } +interface NudgeHandles { + el: HTMLDivElement; + messageEl: HTMLButtonElement; + textEl: HTMLSpanElement; + closeBtn: HTMLButtonElement; + form: HTMLFormElement; + input: HTMLInputElement; +} + +function renderNudge(config: WidgetConfig, strings: Strings): NudgeHandles { + const el = document.createElement('div'); + el.className = 'nudge'; + el.hidden = true; + const author = config.title ?? strings.nudgeAuthor; + el.setAttribute('role', 'region'); + el.setAttribute('aria-label', author); + el.innerHTML = ` +
+ + +
+ +
+ + +
+ `; + (el.querySelector('.nudge-eyebrow') as HTMLElement).textContent = `${author} · ${strings.timeNow}`; + const input = el.querySelector('.nudge-input') as HTMLInputElement; + input.placeholder = strings.nudgePlaceholder; + input.setAttribute('aria-label', strings.nudgePlaceholder); + return { + el, + messageEl: el.querySelector('.nudge-message') as HTMLButtonElement, + textEl: el.querySelector('.nudge-text') as HTMLSpanElement, + closeBtn: el.querySelector('.nudge-close') as HTMLButtonElement, + form: el.querySelector('.nudge-form') as HTMLFormElement, + input, + }; +} + interface PanelHandles { el: HTMLDivElement; closeBtn: HTMLButtonElement; diff --git a/apps/chat-widget/src/widget.test.ts b/apps/chat-widget/src/widget.test.ts index 3cfcaa175..a46136dee 100644 --- a/apps/chat-widget/src/widget.test.ts +++ b/apps/chat-widget/src/widget.test.ts @@ -113,6 +113,8 @@ const baseConfig: WidgetConfig = { corners: 'square', colorScheme: 'auto', showHistory: true, + nudge: null, + nudgeDelayMs: 8000, }; const HASH = 'a'.repeat(64); diff --git a/apps/chat-widget/src/widget.ts b/apps/chat-widget/src/widget.ts index f2330bda2..eaba85bd4 100644 --- a/apps/chat-widget/src/widget.ts +++ b/apps/chat-widget/src/widget.ts @@ -18,6 +18,7 @@ import { createRealtimeClient, type IncomingTyping } from './realtime.ts'; import { mount, type UiController } from './ui.ts'; import { prepareImageForUpload, rejectionForPrepared, uploadToPresigned } from './upload.ts'; import { pickLocale } from './strings/index.ts'; +import { isNudgeSnoozed, snoozeNudge } from './nudge.ts'; import { createVoiceSession, type VoiceSession } from '@getmunin/widget-voice'; function bootstrap(): void { @@ -130,9 +131,38 @@ export function start(config: WidgetConfig): void { function recordMessages(messages: ListedMessage[]): void { for (const m of messages) messagesById.set(m.id, m); + if (messagesById.size > 0) ui.hideNudge(); refreshUnreadBadge(); } + let historyLoaded = false; + let nudgeDue = false; + + function scheduleNudge(): void { + if (config.nudge === null) return; + if (isNudgeSnoozed(config.channelId)) return; + setTimeout(() => { + nudgeDue = true; + maybeShowNudge(); + }, config.nudgeDelayMs); + } + + function maybeShowNudge(): void { + if (!nudgeDue || !historyLoaded) return; + nudgeDue = false; + if (ui.isOpen() || messagesById.size > 0) return; + if (isNudgeSnoozed(config.channelId)) return; + ui.showNudge(config.nudge || strings.nudgeDefault); + } + + function sendFromNudge(text: string): void { + snoozeNudge(config.channelId); + ui.setChatKind('new'); + ui.setView('chat'); + ui.open(); + void sendMessage(text, []); + } + function markLocallyRead(messageId: string): void { locallyRead.add(messageId); refreshUnreadBadge(); @@ -191,6 +221,10 @@ export function start(config: WidgetConfig): void { } } finally { backfillInFlight = false; + if (!historyLoaded) { + historyLoaded = true; + maybeShowNudge(); + } if (backfillPending) { backfillPending = false; void backfill(); @@ -435,6 +469,16 @@ export function start(config: WidgetConfig): void { onVoiceMuteToggle(muted) { toggleVoiceMute(muted); }, + onOpen() { + nudgeDue = false; + if (config.nudge !== null) snoozeNudge(config.channelId); + }, + onNudgeSend(text) { + sendFromNudge(text); + }, + onNudgeDismiss() { + snoozeNudge(config.channelId); + }, }); if (mn.widget) { @@ -526,6 +570,7 @@ export function start(config: WidgetConfig): void { }); realtime.connect(); + scheduleNudge(); window.addEventListener( 'beforeunload', diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index fee42f980..2201335e5 100644 --- a/packages/backend-core/docs-fixtures/skills.json +++ b/packages/backend-core/docs-fixtures/skills.json @@ -213,7 +213,7 @@ "title": "Conv: Set up a chat widget", "description": "Provision a per-channel widget API key, push transcripts and image attachments via POST /v1/widget/messages, and wire the human-handoff webhook.", "mimeType": "text/markdown", - "content": "# Set up a chat widget\nLets an external AI agent running as a chat widget on a customer's website push transcripts into Munin's conversation module. Once the conversation is in Munin, a human in the dashboard can reply, and the customer's webhook receiver tells the external agent to step back.\n\n## 1. Create the channel and mint a widget key\n\nCall `conv_create_widget_channel`:\n\n```jsonc\n{\n \"name\": \"storefront-bot\",\n \"originAllowlist\": [\"https://customer.example\"]\n}\n```\n\nResponse includes `widgetKey: \"mn_widget_…\"` — shown once. Store it server-side.\n\n`originAllowlist` is required — the widget ingest endpoint rejects any request whose `Origin` header doesn't match one of the listed full origins (scheme + host + port, exact match). List every environment that should be allowed to ingest (`https://customer.example`, `https://staging.customer.example`, etc.).\n\nThe widget key is bound to this channel via `api_keys.channel_id`. Rotate with `conv_rotate_widget_key`; update origins with `conv_update_widget_channel`.\n\n### Appearance attributes on the drop-in embed\n\nIf the customer uses Munin's own `widget.js` bundle rather than their own chat UI, these optional `data-*` attributes on the script tag configure it. Add them only when the operator asked for that look.\n\n| Attribute | Values | Effect |\n|---|---|---|\n| `data-munin-fonts` | `bundled` (default), `inherit` | `bundled` ships subset Instrument Serif + JetBrains Mono (~60 KB) and matches the dashboard typography. `inherit` downloads no fonts and renders every string in whatever `font-family` the page applies to ``, so the panel blends into the site's type stack. Sizes, weights and italics are unchanged either way. |\n| `data-munin-theme-color` | hex, e.g. `#0059DE` | Accent for the send button, focus rings, the email-save button, and the unread badge while the launcher keeps its default color. Text on top of it flips between ink and paper for contrast; on filled surfaces a mid-tone that could only carry ink text is darkened by at most 12% so it carries paper text. |\n| `data-munin-launcher-color` | hex | Fill of the round launcher bubble. Defaults to the near-black ink of the panel header, so a brand-colored bubble is an explicit opt-in; the glyph inside follows for contrast. Once set, the unread badge inverts the bubble's colors (glyph color as fill, bubble color as the count), so it stays visible when the bubble matches the theme color. |\n| `data-munin-launcher-icon-color` | hex | Color of the chat glyph in the launcher, overriding the automatic contrast pick. |\n| `data-munin-header-color` | hex | Fill of the panel's top bar (org name + close button). Defaults to the same near-black chrome as the launcher; the text/icon color picks whichever of ink/paper contrasts better. |\n| `data-munin-position` | `bottom-right` (default), `bottom-left` | Launcher corner. |\n| `data-munin-size` | `compact`, `standard` (default), `generous` | Panel size. |\n| `data-munin-org-name` | free text | Header title. Defaults to \"Chat\". |\n| `data-munin-eyebrow` | free text | Small uppercase label above the greeting. |\n| `data-munin-greeting` | free text | Welcome line; the widget renders everything after the first sentence in serif italic and a softer ink tone. Set the `--munin-greeting-emphasis` custom property to `normal` on the embed host for an upright second clause (see below). |\n| `data-munin-locale` | BCP-47 tag | Forces the widget's UI language instead of negotiating from the browser. |\n| `data-munin-color-scheme` | `auto` (default), `light`, `dark` | `auto` follows the visitor's OS/browser preference (`prefers-color-scheme`) and updates live if they switch it; `light`/`dark` pins the panel regardless of OS setting. The launcher bubble, header bar and voice-call screen stay their fixed near-black chrome in every mode unless overridden by the color attributes above — only the panel body (welcome/chat/composer/cards) inverts. |\n| `data-munin-show-history` | `true` (default), `false` | Whether past conversations are listed on the welcome screen. |\n\nAn unrecognized value is a console warning, not an error — the widget falls back to the default and still mounts.\n\n### Visitor attributes on the drop-in embed\n\nThe same bundle accepts a visitor profile, which lands on the contact row the first time the session ingests. Render these for signed-in users on a server-rendered page — they are the embed-side equivalent of the `visitor` object in §2's server-to-server payload.\n\n| Attribute | Effect |\n|---|---|\n| `data-munin-visitor-name` | Display name, trimmed and truncated to 120 chars. |\n| `data-munin-visitor-email` | Email address, format-checked client-side and re-validated server-side. An invalid value is dropped with a console warning rather than sent. |\n| `data-munin-visitor-meta` | Flat JSON object of string / number / boolean values, max 4 KB, e.g. `'{\"plan\":\"pro\",\"accountId\":\"acc_42\"}'`. Lands on `conv_contacts.metadata`. Nested values are dropped with a warning. |\n| `data-munin-meta-` | Shorthand for a single metadata entry; the key is camelized (`data-munin-meta-account-id` → `accountId`). Merged with `data-munin-visitor-meta`, which wins on a key collision. |\n\n**Send a name whenever you have one.** Identity verification (§4) binds a session to an `externalId` and, if you sign one, an email — never a name — so a verified visitor with no `data-munin-visitor-name` still has an unnamed contact row. Every surface that displays a customer falls back through name → email → phone, so without a name the dashboard, the Slack mirror and outreach all show a raw email address, or a generic placeholder when there is no email either.\n\nThese are unrelated to `data-external-id` / `data-user-hash`: the visitor attributes are unverified page-supplied claims, useful for display, while identity verification is what actually authenticates the session. Sending both is the normal case for a signed-in user.\n\n### Overriding styles from the page\n\nThe panel renders inside a shadow root, so the site's own stylesheets cannot reach its internals — but CSS custom properties do inherit across the shadow boundary. Set them on the widget host element from an ordinary page stylesheet:\n\n```css\n[data-munin-widget] {\n --munin-greeting-emphasis: normal;\n}\n```\n\n`--munin-greeting-emphasis` styles the part of the greeting after the first sentence; it takes any `font-style` value and defaults to `italic`. This is the only supported way to restyle panel internals — reaching into `.shadowRoot` from JavaScript depends on class names that change between releases.\n\n### Programmatic open/close\n\nOnce the widget script has executed, `window.mn.widget` exposes:\n\n```ts\nwindow.mn.widget.open(); // opens the panel\nwindow.mn.widget.close(); // closes the panel\nwindow.mn.widget.toggle(); // flips it\nwindow.mn.widget.isOpen(); // current state, boolean\nwindow.mn.widget.ready; // true once the namespace is installed\nawait window.mn.widget.identify(externalId, userHash); // see §4\n```\n\nWire a \"Chat with us\" link anywhere in the page's own nav/footer to `window.mn.widget.toggle()` instead of relying on the launcher bubble alone. Because the script tag has `defer`, `window.mn.widget` isn't installed until after the page has parsed — safe to call from a click handler, not safe to call synchronously in an inline `\n```\n\n`data-external-id` and `data-user-hash` are all-or-nothing: sending one without the other is rejected (`identity_partial`). Render them only for signed-in users; omit both for anonymous visitors. (On browser-direct calls, the same values are passed as the `verifiedExternalId` + `userHash` params.)\n\n### Signing the email too\n\n`data-munin-visitor-email` is a claim the page makes, so Munin treats it as self-reported: the agent may show it but won't use it to look up the customer's orders or bookings. If your backend knows the signed-in user's email, sign it into the hash instead and the email becomes as trustworthy as the `externalId`. That is what lets the agent look up the customer's orders and bookings, and book, change or cancel on their behalf, from the widget.\n\nThe signed payload is length-prefixed so no value can be shifted across a field boundary: each of `['mn.widget-identity.v1', externalId, email]` is written as its UTF-8 byte length, a colon, then the value, and the three are concatenated. Lowercase and trim the email before signing.\n\n```ts\nimport { createHmac } from 'node:crypto';\n\nfunction userHashWithEmail(externalId: string, email: string, secret: string): string {\n const payload = ['mn.widget-identity.v1', externalId, email.trim().toLowerCase()]\n .map((field) => `${Buffer.byteLength(field, 'utf8')}:${field}`)\n .join('');\n return createHmac('sha256', secret).update(payload).digest('hex');\n}\n```\n\nRender it with `data-verified-email` next to the pair, or pass it to `window.mn.widget.identify(externalId, userHash, { email })`:\n\n```html\n\n```\n\nThe email and the hash travel together: a hash computed over the `externalId` alone does not verify once an email is attached, and a hash signed for one email does not verify with another (`identity_verification_failed`). `data-verified-email` without the pair is rejected as `identity_partial`. On browser-direct calls the field is `verifiedEmail`, or the `x-munin-verified-email` header on GETs.\n\nMunin binds the signed email to the signed-in user's end-user record, replacing an email the visitor had typed. It leaves the record alone when another end user already holds that address, so that session gets no email until you merge the two in the dashboard. A leaked hash lets someone act as that user until you rotate the secret, and with a signed email that includes reading their orders and changing their bookings, so keep the hash out of logs and caches like any session credential.\n\nSet `requireVerifiedIdentity: true` on the channel (`conv_create_widget_channel` / `conv_update_widget_channel`) to reject unverified sessions outright; the default (`false`) allows anonymous ingest alongside verified ones.\n\nBecause the widget and the analytics tracker share the same `localStorage` visitor id (`mn.vid`), identifying a visitor to the widget also stitches their prior anonymous analytics history — no separate `window.mn.analytics.identify` call needed for that visitor.\n\n### The same pair authorizes the realtime socket\n\nThe identity is verified twice: once on every HTTP call, and once on the WebSocket handshake that streams agent replies (`wss:///v1/realtime`). The socket takes them as query params — `externalId` (or `verifiedExternalId`, both accepted), `userHash`, and `verifiedEmail` when you sign one — against the same channel secret and the same payload as the HTTP calls. The bundled widget does this for you; you only need the names when you drive `/v1/realtime` yourself.\n\nEvery rejection is answered on the handshake as `403` with the reason in both an `X-Munin-Error` header and a `{\"code\":\"…\"}` body: `identity_partial` (one of the two params missing), `identity_verification_failed` (hash does not match this channel's secret), `identity_required` (`requireVerifiedIdentity` channel, no identity offered), `origin_required` / `origin_not_allowed`. A `401` means the widget key itself was not accepted, before identity was ever considered. Browsers cannot read a failed handshake's status, so reach for `curl -i` or a Node client when diagnosing one.\n\n**The secret is per channel, not per org.** An org with a dev channel and a prod channel has two `channelId`s, two widget keys and two identity secrets; a single `WIDGET_IDENTITY_SECRET` in your app can only match one of them. Signing with the other channel's secret is the usual cause of a widget that sends fine but never receives: put the channel id in the env var name, or read it from the same config that supplies `data-channel-id`.\n\nIf the socket is rejected three handshakes in a row, the widget drops the identity params and reconnects anonymously — replies keep streaming, scoped to the session rather than the user, with a console warning naming the mismatch. It re-arms the identity on the next `window.mn.widget.identify` call or page load, so fixing the secret needs no code change.\n\n### Running the widget and the analytics tracker on the same page\n\nBoth bundles hang off `window.mn`, but each owns its own namespace — `mn.widget` and `mn.analytics` — so nothing collides. A page running both identifies each surface explicitly:\n\n```js\nwindow.mn.widget.identify(externalId, widgetHash); // externalId-only hash\nwindow.mn.analytics.identify(externalId, trackerHash, { email }); // visitor-bound hash\n```\n\nThey take **different hashes signed with different secrets** (see the contrast above), so passing the same value to both will fail one of them. On a server-rendered page, drop the first call and use `data-external-id` + `data-user-hash` on the embed instead.\n\n`window.mn.widget.identify` earns its keep on an SPA that signs a user in without a reload: the widget has already mounted anonymously, and this is the call that claims that anonymous chat session — transcript and all — for the now-known user.\n\n**Migrating from 4.x:** the widget's `identify` used to sit on the shared root as `window.mn.identify`, where it chained with the tracker's identically-named call and one of the two always rejected the hash it received. It is now `window.mn.widget.identify`.\n\n### Sharing a session across subdomains\n\nThe visitor id and session id live in `localStorage` (with a cookie fallback), both scoped to the exact host by default. A conversation started on `www.example.com` therefore does **not** carry over to `app.example.com`. To share one thread across sibling subdomains — e.g. an anonymous chat on the marketing site that continues (and gets claimed) once the visitor signs in on the app — set `data-munin-cookie-domain` to a shared parent domain on every embed:\n\n```html\n\n```\n\nThe session + visitor cookies are then written with that `Domain`, so both subdomains read the same ids and the anonymous thread is claimed on identify. The value must be a suffix of the current host (`.example.com` on `app.example.com`); anything else is ignored client-side to avoid the browser silently dropping the cookie.\n\n## 5. Browser-direct integration (less secure)\n\nIf you must call the endpoint from browser JS, the channel's `originAllowlist` reflects allowed `Origin` headers and the endpoint sets the matching `Access-Control-Allow-Origin`. Anyone on a listed origin can use the key; rotation is one tool call. Server-side is strongly preferred.\n\n## 6. Operations\n\n| Task | How |\n|---|---|\n| Disable the channel | Set `conv_channels.active=false`. Existing keys still auth but ingest returns 403. |\n| Rotate the widget key | `conv_rotate_widget_key`. Old key revoked; existing inflight requests with it 401. |\n| Rotate the identity secret | `conv_rotate_widget_identity_secret`. Previously-issued `data-user-hash` values stop verifying; re-render signed-in pages with freshly-computed hashes. |\n| Tighten `originAllowlist` | `conv_update_widget_channel`. |\n| Inspect a conversation | Standard `conv_*` tools. The `metadata.sessionId`, `metadata.providerMessageId`, and `metadata.url` fields tell you the visitor's session. |\n" + "content": "# Set up a chat widget\nLets an external AI agent running as a chat widget on a customer's website push transcripts into Munin's conversation module. Once the conversation is in Munin, a human in the dashboard can reply, and the customer's webhook receiver tells the external agent to step back.\n\n## 1. Create the channel and mint a widget key\n\nCall `conv_create_widget_channel`:\n\n```jsonc\n{\n \"name\": \"storefront-bot\",\n \"originAllowlist\": [\"https://customer.example\"]\n}\n```\n\nResponse includes `widgetKey: \"mn_widget_…\"` — shown once. Store it server-side.\n\n`originAllowlist` is required — the widget ingest endpoint rejects any request whose `Origin` header doesn't match one of the listed full origins (scheme + host + port, exact match). List every environment that should be allowed to ingest (`https://customer.example`, `https://staging.customer.example`, etc.).\n\nThe widget key is bound to this channel via `api_keys.channel_id`. Rotate with `conv_rotate_widget_key`; update origins with `conv_update_widget_channel`.\n\n### Appearance attributes on the drop-in embed\n\nIf the customer uses Munin's own `widget.js` bundle rather than their own chat UI, these optional `data-*` attributes on the script tag configure it. Add them only when the operator asked for that look.\n\n| Attribute | Values | Effect |\n|---|---|---|\n| `data-munin-fonts` | `bundled` (default), `inherit` | `bundled` ships subset Instrument Serif + JetBrains Mono (~60 KB) and matches the dashboard typography. `inherit` downloads no fonts and renders every string in whatever `font-family` the page applies to ``, so the panel blends into the site's type stack. Sizes, weights and italics are unchanged either way. |\n| `data-munin-theme-color` | hex, e.g. `#0059DE` | Accent for the send button, focus rings, the email-save button, and the unread badge while the launcher keeps its default color. Text on top of it flips between ink and paper for contrast; on filled surfaces a mid-tone that could only carry ink text is darkened by at most 12% so it carries paper text. |\n| `data-munin-launcher-color` | hex | Fill of the round launcher bubble. Defaults to the near-black ink of the panel header, so a brand-colored bubble is an explicit opt-in; the glyph inside follows for contrast. Once set, the unread badge inverts the bubble's colors (glyph color as fill, bubble color as the count), so it stays visible when the bubble matches the theme color. |\n| `data-munin-launcher-icon-color` | hex | Color of the chat glyph in the launcher, overriding the automatic contrast pick. |\n| `data-munin-header-color` | hex | Fill of the panel's top bar (org name + close button). Defaults to the same near-black chrome as the launcher; the text/icon color picks whichever of ink/paper contrasts better. |\n| `data-munin-position` | `bottom-right` (default), `bottom-left` | Launcher corner. |\n| `data-munin-size` | `compact`, `standard` (default), `generous` | Panel size. |\n| `data-munin-org-name` | free text | Header title. Defaults to \"Chat\". |\n| `data-munin-eyebrow` | free text | Small uppercase label above the greeting. |\n| `data-munin-greeting` | free text | Welcome line; the widget renders everything after the first sentence in serif italic and a softer ink tone. Set the `--munin-greeting-emphasis` custom property to `normal` on the embed host for an upright second clause (see below). |\n| `data-munin-locale` | BCP-47 tag | Forces the widget's UI language instead of negotiating from the browser. |\n| `data-munin-color-scheme` | `auto` (default), `light`, `dark` | `auto` follows the visitor's OS/browser preference (`prefers-color-scheme`) and updates live if they switch it; `light`/`dark` pins the panel regardless of OS setting. The launcher bubble, header bar and voice-call screen stay their fixed near-black chrome in every mode unless overridden by the color attributes above — only the panel body (welcome/chat/composer/cards) inverts. |\n| `data-munin-show-history` | `true` (default), `false` | Whether past conversations are listed on the welcome screen. |\n| `data-munin-corners` | `square` (default), `rounded` | Corner style for the panel, its controls and the launcher. `square` gives a square launcher with the unread badge centered on its top-right corner; `rounded` softens the panel and turns the launcher into a circle. |\n| `data-munin-nudge` | free text, or empty | Opt-in teaser shown above the closed launcher: a short message, a dismiss button and an inline input. Leave the value empty for a localized default (\"Hi. Got a question? Type it here and we'll answer right away.\"). Sending from the input opens the panel and posts the message as the first turn of a new conversation. While it shows, the launcher badge reads `1`. It stays hidden when the panel has been opened or the current session already has messages, and for 7 days after the visitor dismisses it or opens the widget. Max 280 chars. |\n| `data-munin-nudge-delay` | seconds, `0`–`3600` | How long after page load the nudge appears. Defaults to `8`. |\n| `data-munin-nudge-color` | hex | Fill of the nudge's message bubble. Defaults to a light tint of `data-munin-theme-color` (stronger in dark mode); the text on it flips between ink and paper for contrast. The small label above the bubble always follows the theme color. |\n\nAn unrecognized value is a console warning, not an error — the widget falls back to the default and still mounts.\n\n### Visitor attributes on the drop-in embed\n\nThe same bundle accepts a visitor profile, which lands on the contact row the first time the session ingests. Render these for signed-in users on a server-rendered page — they are the embed-side equivalent of the `visitor` object in §2's server-to-server payload.\n\n| Attribute | Effect |\n|---|---|\n| `data-munin-visitor-name` | Display name, trimmed and truncated to 120 chars. |\n| `data-munin-visitor-email` | Email address, format-checked client-side and re-validated server-side. An invalid value is dropped with a console warning rather than sent. |\n| `data-munin-visitor-meta` | Flat JSON object of string / number / boolean values, max 4 KB, e.g. `'{\"plan\":\"pro\",\"accountId\":\"acc_42\"}'`. Lands on `conv_contacts.metadata`. Nested values are dropped with a warning. |\n| `data-munin-meta-` | Shorthand for a single metadata entry; the key is camelized (`data-munin-meta-account-id` → `accountId`). Merged with `data-munin-visitor-meta`, which wins on a key collision. |\n\n**Send a name whenever you have one.** Identity verification (§4) binds a session to an `externalId` and, if you sign one, an email — never a name — so a verified visitor with no `data-munin-visitor-name` still has an unnamed contact row. Every surface that displays a customer falls back through name → email → phone, so without a name the dashboard, the Slack mirror and outreach all show a raw email address, or a generic placeholder when there is no email either.\n\nThese are unrelated to `data-external-id` / `data-user-hash`: the visitor attributes are unverified page-supplied claims, useful for display, while identity verification is what actually authenticates the session. Sending both is the normal case for a signed-in user.\n\n### Overriding styles from the page\n\nThe panel renders inside a shadow root, so the site's own stylesheets cannot reach its internals — but CSS custom properties do inherit across the shadow boundary. Set them on the widget host element from an ordinary page stylesheet:\n\n```css\n[data-munin-widget] {\n --munin-greeting-emphasis: normal;\n}\n```\n\n`--munin-greeting-emphasis` styles the part of the greeting after the first sentence; it takes any `font-style` value and defaults to `italic`. This is the only supported way to restyle panel internals — reaching into `.shadowRoot` from JavaScript depends on class names that change between releases.\n\n### Programmatic open/close\n\nOnce the widget script has executed, `window.mn.widget` exposes:\n\n```ts\nwindow.mn.widget.open(); // opens the panel\nwindow.mn.widget.close(); // closes the panel\nwindow.mn.widget.toggle(); // flips it\nwindow.mn.widget.isOpen(); // current state, boolean\nwindow.mn.widget.ready; // true once the namespace is installed\nawait window.mn.widget.identify(externalId, userHash); // see §4\n```\n\nWire a \"Chat with us\" link anywhere in the page's own nav/footer to `window.mn.widget.toggle()` instead of relying on the launcher bubble alone. Because the script tag has `defer`, `window.mn.widget` isn't installed until after the page has parsed — safe to call from a click handler, not safe to call synchronously in an inline `\n```\n\n`data-external-id` and `data-user-hash` are all-or-nothing: sending one without the other is rejected (`identity_partial`). Render them only for signed-in users; omit both for anonymous visitors. (On browser-direct calls, the same values are passed as the `verifiedExternalId` + `userHash` params.)\n\n### Signing the email too\n\n`data-munin-visitor-email` is a claim the page makes, so Munin treats it as self-reported: the agent may show it but won't use it to look up the customer's orders or bookings. If your backend knows the signed-in user's email, sign it into the hash instead and the email becomes as trustworthy as the `externalId`. That is what lets the agent look up the customer's orders and bookings, and book, change or cancel on their behalf, from the widget.\n\nThe signed payload is length-prefixed so no value can be shifted across a field boundary: each of `['mn.widget-identity.v1', externalId, email]` is written as its UTF-8 byte length, a colon, then the value, and the three are concatenated. Lowercase and trim the email before signing.\n\n```ts\nimport { createHmac } from 'node:crypto';\n\nfunction userHashWithEmail(externalId: string, email: string, secret: string): string {\n const payload = ['mn.widget-identity.v1', externalId, email.trim().toLowerCase()]\n .map((field) => `${Buffer.byteLength(field, 'utf8')}:${field}`)\n .join('');\n return createHmac('sha256', secret).update(payload).digest('hex');\n}\n```\n\nRender it with `data-verified-email` next to the pair, or pass it to `window.mn.widget.identify(externalId, userHash, { email })`:\n\n```html\n\n```\n\nThe email and the hash travel together: a hash computed over the `externalId` alone does not verify once an email is attached, and a hash signed for one email does not verify with another (`identity_verification_failed`). `data-verified-email` without the pair is rejected as `identity_partial`. On browser-direct calls the field is `verifiedEmail`, or the `x-munin-verified-email` header on GETs.\n\nMunin binds the signed email to the signed-in user's end-user record, replacing an email the visitor had typed. It leaves the record alone when another end user already holds that address, so that session gets no email until you merge the two in the dashboard. A leaked hash lets someone act as that user until you rotate the secret, and with a signed email that includes reading their orders and changing their bookings, so keep the hash out of logs and caches like any session credential.\n\nSet `requireVerifiedIdentity: true` on the channel (`conv_create_widget_channel` / `conv_update_widget_channel`) to reject unverified sessions outright; the default (`false`) allows anonymous ingest alongside verified ones.\n\nBecause the widget and the analytics tracker share the same `localStorage` visitor id (`mn.vid`), identifying a visitor to the widget also stitches their prior anonymous analytics history — no separate `window.mn.analytics.identify` call needed for that visitor.\n\n### The same pair authorizes the realtime socket\n\nThe identity is verified twice: once on every HTTP call, and once on the WebSocket handshake that streams agent replies (`wss:///v1/realtime`). The socket takes them as query params — `externalId` (or `verifiedExternalId`, both accepted), `userHash`, and `verifiedEmail` when you sign one — against the same channel secret and the same payload as the HTTP calls. The bundled widget does this for you; you only need the names when you drive `/v1/realtime` yourself.\n\nEvery rejection is answered on the handshake as `403` with the reason in both an `X-Munin-Error` header and a `{\"code\":\"…\"}` body: `identity_partial` (one of the two params missing), `identity_verification_failed` (hash does not match this channel's secret), `identity_required` (`requireVerifiedIdentity` channel, no identity offered), `origin_required` / `origin_not_allowed`. A `401` means the widget key itself was not accepted, before identity was ever considered. Browsers cannot read a failed handshake's status, so reach for `curl -i` or a Node client when diagnosing one.\n\n**The secret is per channel, not per org.** An org with a dev channel and a prod channel has two `channelId`s, two widget keys and two identity secrets; a single `WIDGET_IDENTITY_SECRET` in your app can only match one of them. Signing with the other channel's secret is the usual cause of a widget that sends fine but never receives: put the channel id in the env var name, or read it from the same config that supplies `data-channel-id`.\n\nIf the socket is rejected three handshakes in a row, the widget drops the identity params and reconnects anonymously — replies keep streaming, scoped to the session rather than the user, with a console warning naming the mismatch. It re-arms the identity on the next `window.mn.widget.identify` call or page load, so fixing the secret needs no code change.\n\n### Running the widget and the analytics tracker on the same page\n\nBoth bundles hang off `window.mn`, but each owns its own namespace — `mn.widget` and `mn.analytics` — so nothing collides. A page running both identifies each surface explicitly:\n\n```js\nwindow.mn.widget.identify(externalId, widgetHash); // externalId-only hash\nwindow.mn.analytics.identify(externalId, trackerHash, { email }); // visitor-bound hash\n```\n\nThey take **different hashes signed with different secrets** (see the contrast above), so passing the same value to both will fail one of them. On a server-rendered page, drop the first call and use `data-external-id` + `data-user-hash` on the embed instead.\n\n`window.mn.widget.identify` earns its keep on an SPA that signs a user in without a reload: the widget has already mounted anonymously, and this is the call that claims that anonymous chat session — transcript and all — for the now-known user.\n\n**Migrating from 4.x:** the widget's `identify` used to sit on the shared root as `window.mn.identify`, where it chained with the tracker's identically-named call and one of the two always rejected the hash it received. It is now `window.mn.widget.identify`.\n\n### Sharing a session across subdomains\n\nThe visitor id and session id live in `localStorage` (with a cookie fallback), both scoped to the exact host by default. A conversation started on `www.example.com` therefore does **not** carry over to `app.example.com`. To share one thread across sibling subdomains — e.g. an anonymous chat on the marketing site that continues (and gets claimed) once the visitor signs in on the app — set `data-munin-cookie-domain` to a shared parent domain on every embed:\n\n```html\n\n```\n\nThe session + visitor cookies are then written with that `Domain`, so both subdomains read the same ids and the anonymous thread is claimed on identify. The value must be a suffix of the current host (`.example.com` on `app.example.com`); anything else is ignored client-side to avoid the browser silently dropping the cookie.\n\n## 5. Browser-direct integration (less secure)\n\nIf you must call the endpoint from browser JS, the channel's `originAllowlist` reflects allowed `Origin` headers and the endpoint sets the matching `Access-Control-Allow-Origin`. Anyone on a listed origin can use the key; rotation is one tool call. Server-side is strongly preferred.\n\n## 6. Operations\n\n| Task | How |\n|---|---|\n| Disable the channel | Set `conv_channels.active=false`. Existing keys still auth but ingest returns 403. |\n| Rotate the widget key | `conv_rotate_widget_key`. Old key revoked; existing inflight requests with it 401. |\n| Rotate the identity secret | `conv_rotate_widget_identity_secret`. Previously-issued `data-user-hash` values stop verifying; re-render signed-in pages with freshly-computed hashes. |\n| Tighten `originAllowlist` | `conv_update_widget_channel`. |\n| Inspect a conversation | Standard `conv_*` tools. The `metadata.sessionId`, `metadata.providerMessageId`, and `metadata.url` fields tell you the visitor's session. |\n" }, { "uri": "skill://conv/setup-email-and-widget-channels", diff --git a/packages/backend-core/src/modules/conv/skills/setup-chat-widget.md b/packages/backend-core/src/modules/conv/skills/setup-chat-widget.md index d96b8681f..b79ad02d4 100644 --- a/packages/backend-core/src/modules/conv/skills/setup-chat-widget.md +++ b/packages/backend-core/src/modules/conv/skills/setup-chat-widget.md @@ -43,6 +43,10 @@ If the customer uses Munin's own `widget.js` bundle rather than their own chat U | `data-munin-locale` | BCP-47 tag | Forces the widget's UI language instead of negotiating from the browser. | | `data-munin-color-scheme` | `auto` (default), `light`, `dark` | `auto` follows the visitor's OS/browser preference (`prefers-color-scheme`) and updates live if they switch it; `light`/`dark` pins the panel regardless of OS setting. The launcher bubble, header bar and voice-call screen stay their fixed near-black chrome in every mode unless overridden by the color attributes above — only the panel body (welcome/chat/composer/cards) inverts. | | `data-munin-show-history` | `true` (default), `false` | Whether past conversations are listed on the welcome screen. | +| `data-munin-corners` | `square` (default), `rounded` | Corner style for the panel, its controls and the launcher. `square` gives a square launcher with the unread badge centered on its top-right corner; `rounded` softens the panel and turns the launcher into a circle. | +| `data-munin-nudge` | free text, or empty | Opt-in teaser shown above the closed launcher: a short message, a dismiss button and an inline input. Leave the value empty for a localized default ("Hi. Got a question? Type it here and we'll answer right away."). Sending from the input opens the panel and posts the message as the first turn of a new conversation. While it shows, the launcher badge reads `1`. It stays hidden when the panel has been opened or the current session already has messages, and for 7 days after the visitor dismisses it or opens the widget. Max 280 chars. | +| `data-munin-nudge-delay` | seconds, `0`–`3600` | How long after page load the nudge appears. Defaults to `8`. | +| `data-munin-nudge-color` | hex | Fill of the nudge's message bubble. Defaults to a light tint of `data-munin-theme-color` (stronger in dark mode); the text on it flips between ink and paper for contrast. The small label above the bubble always follows the theme color. | An unrecognized value is a console warning, not an error — the widget falls back to the default and still mounts. diff --git a/packages/docs-pages/src/guides/chat-widget/page.tsx b/packages/docs-pages/src/guides/chat-widget/page.tsx index c86bba4d3..3046e6f98 100644 --- a/packages/docs-pages/src/guides/chat-widget/page.tsx +++ b/packages/docs-pages/src/guides/chat-widget/page.tsx @@ -160,6 +160,30 @@ export default function WidgetGuide() {
Set to "false" to hide the past-conversation list on the welcome screen.
+
data-munin-corners
+
+ "square" (default) or "rounded". Square gives a + square launcher with the unread badge centered on its corner; rounded softens the panel and + its controls and turns the launcher into a circle. +
+
data-munin-nudge
+
+ Opt-in teaser above the closed launcher: a short message with a dismiss button and an inline + input. Leave the value empty for a localized default. Whatever the visitor types there opens + the panel and starts a conversation with it. It stays away once the panel has been opened or + the visitor already has messages, and for seven days after it is dismissed. Up to 280 + characters. +
+
data-munin-nudge-delay
+
+ Seconds after page load before the nudge appears, 0–3600. + Defaults to 8. +
+
data-munin-nudge-color
+
+ Hex fill for the nudge’s message bubble. Left off, the bubble is a light tint of the + theme color; either way the text on it flips between ink and paper for contrast. +

Whichever language wins travels with the conversation when it starts: the agent is asked to