Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/widget-nudge-mobile-compact.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 20 additions & 32 deletions apps/chat-widget/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -181,7 +183,7 @@ button {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
opacity: 0;
transform: translateY(8px);
transition:
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 42 additions & 2 deletions apps/chat-widget/src/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
});
});
});
24 changes: 17 additions & 7 deletions apps/chat-widget/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -1219,12 +1233,9 @@ function renderNudge(config: WidgetConfig, strings: Strings): NudgeHandles {
el.setAttribute('role', 'region');
el.setAttribute('aria-label', author);
el.innerHTML = `
<div class="nudge-head">
<span class="nudge-eyebrow"></span>
<button type="button" class="nudge-close" aria-label="${escapeAttr(strings.nudgeDismissAriaLabel)}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<button type="button" class="nudge-close" aria-label="${escapeAttr(strings.nudgeDismissAriaLabel)}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
<button type="button" class="nudge-message" aria-label="${escapeAttr(strings.launcherAriaLabel)}">
<span class="nudge-text" aria-live="polite"></span>
</button>
Expand All @@ -1233,7 +1244,6 @@ function renderNudge(config: WidgetConfig, strings: Strings): NudgeHandles {
<button type="submit" class="nudge-send" aria-label="${escapeAttr(strings.sendAriaLabel)}">→</button>
</form>
`;
(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);
Expand Down
2 changes: 1 addition & 1 deletion packages/backend-core/docs-fixtures/skills.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions packages/docs-pages/src/guides/chat-widget/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</dd>
<dt>data-munin-nudge-delay</dt>
<dd>
Expand Down
Loading