From d91cea408723b8ea373263ca4051ec09447a0061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kjell=20Rune=20Mons=C3=B8?= Date: Thu, 1 Oct 2026 22:09:44 +0200 Subject: [PATCH] feat(chat-widget): lift the nudge pieces and compact it on phones The nudge drops its eyebrow, floats the dismiss button as a round chip and gives the message and field a soft shadow. On screens 600px wide or less it shows only the message and the dismiss button, and steps aside without snoozing once the visitor scrolls most of a screen away. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/widget-nudge-mobile-compact.md | 12 +++++ apps/chat-widget/src/styles.ts | 52 +++++++------------ apps/chat-widget/src/ui.test.ts | 44 +++++++++++++++- apps/chat-widget/src/ui.ts | 24 ++++++--- .../backend-core/docs-fixtures/skills.json | 2 +- .../modules/conv/skills/setup-chat-widget.md | 4 +- .../src/guides/chat-widget/page.tsx | 5 +- 7 files changed, 97 insertions(+), 46 deletions(-) create mode 100644 .changeset/widget-nudge-mobile-compact.md diff --git a/.changeset/widget-nudge-mobile-compact.md b/.changeset/widget-nudge-mobile-compact.md new file mode 100644 index 000000000..6d403cdef --- /dev/null +++ b/.changeset/widget-nudge-mobile-compact.md @@ -0,0 +1,12 @@ +--- +'@getmunin/chat-widget': patch +'@getmunin/backend-core': patch +'@getmunin/docs-pages': patch +--- + +Chat widget nudge: lifted pieces, and a compact form on phones. + +- The nudge drops the eyebrow (org name · now). The dismiss button is now a round 32px chip floating above the message, and the message bubble and input field each carry a soft shadow, so the three pieces read as lifted off the page. The field uses the lighter rule border instead of the ink one. +- On phone-sized screens (600px wide or less, where the panel goes full-screen) the nudge shows only the message and the dismiss button; tapping the message opens the full-screen chat with the composer focused. The inline input was the piece that fought the on-screen keyboard over a fixed-position element, and it saved no steps on a phone since sending opens the full-screen panel anyway. +- On phones the nudge also steps aside once the visitor scrolls more than 60% of a screen away from where it appeared. That is not a dismissal: it is not snoozed and returns on the next page load. +- `skill://conv/setup-chat-widget` and the chat-widget docs guide describe the mobile behavior. diff --git a/apps/chat-widget/src/styles.ts b/apps/chat-widget/src/styles.ts index 52c0841c4..df5e7beea 100644 --- a/apps/chat-widget/src/styles.ts +++ b/apps/chat-widget/src/styles.ts @@ -19,6 +19,7 @@ const DARK_VARS = String.raw` --munin-verdigris: #62C39C; --munin-verdigris-tint: #1B382A; --munin-nudge-mix: 24%; + --munin-nudge-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.35); --munin-agent-tint: #1E252C; --munin-self-tint: #272C33; --munin-field-border: rgba(255, 255, 255, 0.34); @@ -71,6 +72,7 @@ const BASE_CSS = String.raw` --munin-agent-tint: #DCE2E8; --munin-self-tint: #E8E4DC; --munin-field-border: var(--munin-ink); + --munin-nudge-shadow: 0 10px 28px rgba(15, 20, 25, 0.10), 0 1px 3px rgba(15, 20, 25, 0.08); --munin-r-panel: 0; --munin-r-surface: 0; @@ -181,7 +183,7 @@ button { box-sizing: border-box; display: flex; flex-direction: column; - gap: 10px; + gap: 8px; opacity: 0; transform: translateY(8px); transition: @@ -191,45 +193,29 @@ button { .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); + width: 32px; + height: 32px; + border-radius: 999px; background: var(--munin-paper); - color: var(--munin-ink-mute); + color: var(--munin-ink); + box-shadow: var(--munin-nudge-shadow); 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; } +.root[data-position='bottom-right'] .nudge-close { align-self: flex-end; } +.root[data-position='bottom-left'] .nudge-close { align-self: flex-start; } +.nudge-close:hover { background: var(--munin-paper-deep); } +.nudge-close svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.25; } .nudge-message { display: block; width: 100%; text-align: left; - padding: 12px 14px; + padding: 14px 16px; background: var(--munin-nudge-bg, color-mix(in srgb, var(--munin-theme) var(--munin-nudge-mix, 12%), var(--munin-paper))); border-radius: 14px; + box-shadow: var(--munin-nudge-shadow); color: var(--munin-nudge-fg, var(--munin-ink)); font-size: 14.5px; line-height: 1.45; @@ -238,9 +224,10 @@ button { .nudge-form { display: flex; align-items: stretch; - border: 1px solid var(--munin-field-border); + border: 1px solid var(--munin-rule); border-radius: var(--munin-r-surface); background: var(--munin-paper); + box-shadow: var(--munin-nudge-shadow); overflow: hidden; } .nudge-form:focus-within { @@ -252,16 +239,16 @@ button { min-width: 0; border: 0; background: transparent; - padding: 10px 12px; + padding: 13px 14px; font: inherit; - font-size: 13.5px; + font-size: 14px; color: var(--munin-ink); outline: none; } .nudge-input::placeholder { color: var(--munin-ink-mute); } .nudge-send { flex-shrink: 0; - width: 44px; + width: 46px; border-left: 1px solid var(--munin-rule); color: var(--munin-send); font-size: 18px; @@ -1081,6 +1068,7 @@ button { .root[data-position='bottom-right'] { right: 16px; } .root[data-position='bottom-left'] { left: 16px; } .nudge { max-width: calc(100vw - 32px); } + .nudge-form { display: none; } .root[data-size] .panel { position: fixed; diff --git a/apps/chat-widget/src/ui.test.ts b/apps/chat-widget/src/ui.test.ts index 15d31f10e..cbdb3a503 100644 --- a/apps/chat-widget/src/ui.test.ts +++ b/apps/chat-widget/src/ui.test.ts @@ -1028,7 +1028,7 @@ describe('ui: powered-by credit links', () => { }); describe('ui: nudge', () => { - it('shows the nudge text with the org eyebrow and a badge of 1', () => { + it('shows the nudge text, labelled by the org name, with a badge of 1', () => { controller = mount({ ...baseConfig, title: 'Acme' }, strings, { onSend: () => {}, onTypingIntent: () => {}, @@ -1037,7 +1037,7 @@ describe('ui: nudge', () => { 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($('.nudge').getAttribute('aria-label')).toBe('Acme'); expect($('.launcher-badge').textContent).toBe('1'); expect(controller.isNudgeVisible()).toBe(true); }); @@ -1122,4 +1122,44 @@ describe('ui: nudge', () => { controller.showNudge('Hi'); expect(controller.isNudgeVisible()).toBe(false); }); + + describe('on scroll', () => { + const origMatchMedia = window.matchMedia; + + function scrollTo(y: number): void { + Object.defineProperty(window, 'scrollY', { value: y, configurable: true }); + window.dispatchEvent(new Event('scroll')); + } + + afterEach(() => { + window.matchMedia = origMatchMedia; + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + }); + + it('steps aside on a phone once the visitor scrolls most of a screen, without reporting a dismissal', () => { + window.matchMedia = vi.fn().mockReturnValue({ matches: true }); + Object.defineProperty(window, 'innerHeight', { value: 700, configurable: true }); + const onNudgeDismiss = vi.fn(); + controller = mount(baseConfig, strings, { + onSend: () => {}, + onTypingIntent: () => {}, + onNudgeDismiss, + }); + controller.showNudge('Hi'); + scrollTo(200); + expect(controller.isNudgeVisible()).toBe(true); + scrollTo(500); + expect(controller.isNudgeVisible()).toBe(false); + expect(($('.launcher-badge')).hidden).toBe(true); + expect(onNudgeDismiss).not.toHaveBeenCalled(); + }); + + it('stays put on larger viewports', () => { + window.matchMedia = vi.fn().mockReturnValue({ matches: false }); + controller = mount(baseConfig, strings, { onSend: () => {}, onTypingIntent: () => {} }); + controller.showNudge('Hi'); + scrollTo(5000); + expect(controller.isNudgeVisible()).toBe(true); + }); + }); }); diff --git a/apps/chat-widget/src/ui.ts b/apps/chat-widget/src/ui.ts index 163f52ac5..0ef76a304 100644 --- a/apps/chat-widget/src/ui.ts +++ b/apps/chat-widget/src/ui.ts @@ -23,6 +23,7 @@ import { export type ConnectionLabel = 'connected' | 'reconnecting' | 'closed' | 'idle' | 'connecting'; const RECONNECT_STATUS_GRACE_MS = 1500; +const NUDGE_MOBILE_SCROLL_HIDE_RATIO = 0.6; export interface UploadedAttachment { id: string; @@ -881,6 +882,14 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U paintLauncherBadge(); } + let nudgeScrollStart = 0; + + function onNudgeScroll(): void { + if (Math.abs(window.scrollY - nudgeScrollStart) > window.innerHeight * NUDGE_MOBILE_SCROLL_HIDE_RATIO) { + hideNudge(); + } + } + function showNudge(text: string): void { if (panelOpen) return; nudge.textEl.textContent = text; @@ -889,12 +898,17 @@ export function mount(config: WidgetConfig, strings: Strings, hooks: UiHooks): U requestAnimationFrame(() => { if (nudgeVisible) nudge.el.classList.add('open'); }); + if (fullscreenMql?.matches) { + nudgeScrollStart = window.scrollY; + window.addEventListener('scroll', onNudgeScroll, { passive: true }); + } paintLauncherBadge(); } function hideNudge(): void { if (!nudgeVisible) return; nudgeVisible = false; + window.removeEventListener('scroll', onNudgeScroll); nudge.el.classList.remove('open'); nudge.el.hidden = true; paintLauncherBadge(); @@ -1219,12 +1233,9 @@ function renderNudge(config: WidgetConfig, strings: Strings): NudgeHandles { el.setAttribute('role', 'region'); el.setAttribute('aria-label', author); el.innerHTML = ` -
- - -
+ @@ -1233,7 +1244,6 @@ function renderNudge(config: WidgetConfig, strings: Strings): NudgeHandles { `; - (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); diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index 2201335e5..7bfccb2bd 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| `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" + "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. On phone-sized screens (600px wide or less) it shows only the message and the dismiss button — tapping the message opens the full-screen chat — and it hides once the visitor scrolls most of a screen away, without snoozing. 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. |\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 b79ad02d4..3ca3f22b4 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 @@ -44,9 +44,9 @@ If the customer uses Munin's own `widget.js` bundle rather than their own chat U | `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` | 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. On phone-sized screens (600px wide or less) it shows only the message and the dismiss button — tapping the message opens the full-screen chat — and it hides once the visitor scrolls most of a screen away, without snoozing. 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. | +| `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. | 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 3046e6f98..2e3891235 100644 --- a/packages/docs-pages/src/guides/chat-widget/page.tsx +++ b/packages/docs-pages/src/guides/chat-widget/page.tsx @@ -171,8 +171,9 @@ export default function WidgetGuide() { 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. + the visitor already has messages, and for seven days after it is dismissed. On phones it + drops the input and shows just the message, which opens the full-screen chat when tapped, + and it steps aside once the visitor scrolls on. Up to 280 characters.
data-munin-nudge-delay