diff --git a/.changeset/fullpage-logo-icon-and-logo-url.md b/.changeset/fullpage-logo-icon-and-logo-url.md new file mode 100644 index 0000000..8993700 --- /dev/null +++ b/.changeset/fullpage-logo-icon-and-logo-url.md @@ -0,0 +1,17 @@ +--- +'@smooai/chat-widget': minor +--- + +Full-page header: square Smooth icon default + customizable `logoUrl`. + +**New default icon.** The full-page header avatar previously rendered the full "smooth" wordmark (a wide 550×135 SVG) crammed into the square tile, so it overflowed and looked broken — and it stamped Smoo branding onto customers' pages. It now renders the square Smooth icon (the stylized `th` glyph, `assets/smooth-icon.svg`, 150×150) which sits cleanly `contain`ed and centered in the tile. + +**New `logoUrl` config key.** Host pages can now brand the full-page header with their own logo: + +```js +mountFullPageChat({ endpoint: 'wss://ai.smoo.ai/ws', agentId: '…', logoUrl: 'https://cdn.acme.com/logo.svg' }); +// or declaratively: +// +``` + +When set, the header renders `` sized to `contain` within the tile; otherwise it falls back to the Smooth icon. **Security:** `logoUrl` is validated to absolute `http(s)` only (via the existing `safeHttpUrl` guard) — `javascript:`/`data:`/relative URLs are dropped — and escaped into the `src` attribute, so a hostile config can't inject script. diff --git a/README.md b/README.md index 6a76fb7..008ed3f 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ Declarative attributes mirror the programmatic [`ChatWidgetConfig`](./src/config | `endpoint` | `endpoint` | **Required.** smooth-operator WebSocket URL. | | `agent-id` | `agentId` | **Required.** UUID of the agent. | | `agent-name` | `agentName` | Header label + monogram. Default `Assistant`. | +| `logo-url` | `logoUrl` | Brand logo in the full-page header tile (`http(s)` only). Falls back to the Smooth icon. | | `placeholder` | `placeholder` | Composer placeholder. | | `greeting` | `greeting` | Shown before the first message. | | `mode` | `mode` | `popover` (default) or `fullpage`. | diff --git a/assets/smooth-icon.svg b/assets/smooth-icon.svg new file mode 100644 index 0000000..32ea626 --- /dev/null +++ b/assets/smooth-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/config.test.ts b/src/config.test.ts index 1a1558e..0c39105 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -68,6 +68,15 @@ describe('resolveConfig', () => { const r = resolveConfig({ ...base, examplePrompts: ['a', ' ', 'b', 'c', 'd', 'e', 'f'] }); expect(r.examplePrompts).toEqual(['a', 'b', 'c', 'd', 'e']); }); + + it('keeps a safe http(s) logoUrl and drops dangerous/relative ones (XSS guard)', () => { + expect(resolveConfig({ ...base, logoUrl: 'https://cdn.example.com/l.png' }).logoUrl).toBe('https://cdn.example.com/l.png'); + // eslint-disable-next-line no-script-url + expect(resolveConfig({ ...base, logoUrl: 'javascript:alert(1)' }).logoUrl).toBeUndefined(); + expect(resolveConfig({ ...base, logoUrl: 'data:text/html,' }).shadowRoot!; + expect(sr2.querySelector('.header .logo-img')).toBeNull(); + }); + // Configure non-attribute options (examplePrompts / require*) before mount. function mountCfg(cfg: Record): HTMLElement { defineChatWidget(); diff --git a/src/element.ts b/src/element.ts index 020caf2..b434b82 100644 --- a/src/element.ts +++ b/src/element.ts @@ -18,7 +18,7 @@ import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber- import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js'; import { needsUserInfo, resolveConfig } from './config.js'; import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js'; -import { SMOOTH_LOGO_SVG } from './logo.js'; +import { SMOOTH_ICON_SVG } from './logo.js'; import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js'; import { buildStyles } from './styles.js'; @@ -49,7 +49,7 @@ function phoneToE164(value: string): string | null { } } -const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'placeholder', 'greeting', 'start-open', 'mode'] as const; +const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode'] as const; /** * Inline SVG icons (static, trusted strings — never interpolated with user data). @@ -195,6 +195,7 @@ export class SmoothAgentChatElement extends HTMLElement { mode, agentId, agentName: this.overrides.agentName ?? this.getAttribute('agent-name') ?? undefined, + logoUrl: this.overrides.logoUrl ?? this.getAttribute('logo-url') ?? undefined, userName: this.overrides.userName, userEmail: this.overrides.userEmail, userPhone: this.overrides.userPhone, @@ -264,13 +265,19 @@ export class SmoothAgentChatElement extends HTMLElement { const style = document.createElement('style'); style.textContent = buildStyles(resolved.theme, resolved.mode); - // Header: in full-page mode lead with the Smooth logo in the avatar tile + // Header: in full-page mode lead with the brand logo in the avatar tile // and a subtle "powered by" tag; in popover mode show a brand-colored - // monogram avatar + a compact close (collapse) button. + // monogram avatar + a compact close (collapse) button. The logo defaults + // to the square Smooth icon, but a host page can override it with + // `logoUrl` (already sanitized to http(s)-only by resolveConfig; escaped + // here so it can't break out of the src attribute). const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || 'A').toUpperCase()); + const headerLogo = resolved.logoUrl + ? `` + : SMOOTH_ICON_SVG; const header = fullpage ? `
-
${SMOOTH_LOGO_SVG}
+
${headerLogo}
${escapeHtml(resolved.agentName)} diff --git a/src/logo.ts b/src/logo.ts index 247e106..f7991ac 100644 --- a/src/logo.ts +++ b/src/logo.ts @@ -1,9 +1,14 @@ /** - * The Smooth logo, inlined as an SVG string so the full-page header can render - * it without a separate network fetch (the IIFE bundle is self-contained). + * The Smooth logo + icon, inlined as SVG strings so the full-page header can + * render them without a separate network fetch (the IIFE bundle is + * self-contained). * - * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with: - * node -e ... (see the commit that added this file) + * GENERATED from `assets/smooth-logo.svg` / `assets/smooth-icon.svg` — do not + * edit by hand. Regenerate with: + * node -e 'const fs=require("fs");process.stdout.write(JSON.stringify(fs.readFileSync("assets/smooth-icon.svg","utf8")))' */ /* eslint-disable */ export const SMOOTH_LOGO_SVG = "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n"; + +/** The square Smooth icon (the stylized `th` glyph) — used as the default full-page header avatar. */ +export const SMOOTH_ICON_SVG = "\n\n \n \n \n \n \n \n \n \n"; diff --git a/src/styles.ts b/src/styles.ts index f66109d..073b843 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -176,8 +176,9 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove 0 1px 0 rgba(255, 255, 255, .25) inset; } .avatar svg { width: 22px; height: 22px; } -.avatar .logo-wrap { display: flex; } +.avatar .logo-wrap { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; } .avatar .logo { height: 22px; width: auto; display: block; } +.avatar .logo-img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; display: block; border-radius: 9px; } .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; } .title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; } .status { @@ -226,6 +227,7 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove .panel.fullpage .header { padding: 18px 22px; } .panel.fullpage .avatar { width: 44px; height: 44px; } .panel.fullpage .avatar .logo { height: 26px; } +.panel.fullpage .avatar svg { width: 28px; height: 28px; } /* ────────────────────────────── Messages ──────────────────────────── */ .messages {