diff --git a/.changeset/poweredby-link-toggle.md b/.changeset/poweredby-link-toggle.md new file mode 100644 index 0000000..a14edf2 --- /dev/null +++ b/.changeset/poweredby-link-toggle.md @@ -0,0 +1,5 @@ +--- +'@smooai/chat-widget': patch +--- + +The "powered by smooth-operator" tag (full-page header) and footer now link to the smooth-operator GitHub repo (opens in a new tab). Added a `hide-branding` element attribute (`hideBranding` config key) to hide the branding in both render paths; branding is shown by default. The "Restore my chats" footer affordance is preserved independently of the toggle. diff --git a/README.md b/README.md index 008ed3f..01f0450 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ Declarative attributes mirror the programmatic [`ChatWidgetConfig`](./src/config | `greeting` | `greeting` | Shown before the first message. | | `mode` | `mode` | `popover` (default) or `fullpage`. | | `start-open` | `startOpen` | Open the panel immediately (no launcher click). | +| `hide-branding` | `hideBranding` | Hide the "powered by smooth-operator" tag + footer link. Default shown. | | — | `theme` | Brand colors (see [Theming](#theming)). | | — | `userName` / `userEmail` | Optional participant identity. | diff --git a/src/config.ts b/src/config.ts index 91d81dd..da9b041 100644 --- a/src/config.ts +++ b/src/config.ts @@ -95,6 +95,12 @@ export interface ChatWidgetConfig { connectionErrorMessage?: string; /** Start the panel open instead of collapsed to the launcher. */ startOpen?: boolean; + /** + * Hide the "powered by smooth-operator" branding in the header tag and the + * composer footer. Defaults to `false` (branding shown). The `hide-branding` + * HTML attribute maps to this. + */ + hideBranding?: boolean; /** * Suggested starter prompts shown as clickable chips before the first message. * Clicking one sends it. Capped at 5 for layout. @@ -172,6 +178,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig { greeting: config.greeting ?? 'Hi! How can I help you today?', connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.", startOpen: config.startOpen ?? false, + hideBranding: config.hideBranding ?? false, examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5), requireName: config.requireName ?? false, requireEmail: config.requireEmail ?? false, diff --git a/src/element.test.ts b/src/element.test.ts index dbaf186..6388664 100644 --- a/src/element.test.ts +++ b/src/element.test.ts @@ -281,6 +281,69 @@ describe(' render', () => { }); }); +describe('powered-by branding (link + hide-branding toggle)', () => { + const REPO_URL = 'https://github.com/SmooAI/smooth-operator'; + + afterEach(() => { + document.body.innerHTML = ''; + }); + + function mount(attrs: Record): HTMLElement { + defineChatWidget(); + const el = document.createElement(ELEMENT_TAG); + el.setAttribute('endpoint', 'wss://e/ws'); + el.setAttribute('agent-id', 'a1'); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + document.body.appendChild(el); + return el; + } + function mountCfg(cfg: Record): HTMLElement { + defineChatWidget(); + const el = document.createElement(ELEMENT_TAG) as HTMLElement & { configure: (c: Record) => void }; + el.setAttribute('endpoint', 'wss://e/ws'); + el.setAttribute('agent-id', 'a1'); + el.configure(cfg); + document.body.appendChild(el); + return el; + } + + it('by default links the composer footer to the smooth-operator repo (new tab)', () => { + const link = mount({}).shadowRoot!.querySelector('.footer a') as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.getAttribute('href')).toBe(REPO_URL); + expect(link?.getAttribute('target')).toBe('_blank'); + expect(link?.getAttribute('rel')).toContain('noopener'); + expect(link?.textContent).toContain('smooth'); + }); + + it('by default shows a linked "powered by" tag in the full-page header', () => { + const tag = mount({ mode: 'fullpage' }).shadowRoot!.querySelector('.powered') as HTMLAnchorElement | null; + expect(tag).not.toBeNull(); + expect(tag?.tagName).toBe('A'); + expect(tag?.getAttribute('href')).toBe(REPO_URL); + expect(tag?.getAttribute('target')).toBe('_blank'); + }); + + it('hide-branding attribute drops the header tag and the footer branding link (full-page)', () => { + const sr = mount({ mode: 'fullpage', 'hide-branding': '' }).shadowRoot!; + expect(sr.querySelector('.powered')).toBeNull(); + // No link to the repo anywhere in the footer. + expect(sr.querySelector('.footer a')).toBeNull(); + }); + + it('hideBranding via configure() hides branding but keeps the "Restore my chats" affordance', () => { + const sr = mountCfg({ hideBranding: true }).shadowRoot!; + expect(sr.querySelector('.footer a')).toBeNull(); + expect(sr.querySelector('.restore-link')).not.toBeNull(); + }); + + it('hide-branding with restore disabled omits the footer entirely (popover)', () => { + const sr = mountCfg({ hideBranding: true, allowChatRestore: false }).shadowRoot!; + expect(sr.querySelector('.powered')).toBeNull(); + expect(sr.querySelector('.footer')).toBeNull(); + }); +}); + describe('fullpage container sizing (composer-clip regression)', () => { afterEach(() => { document.body.innerHTML = ''; diff --git a/src/element.ts b/src/element.ts index ed2324e..ec4c87f 100644 --- a/src/element.ts +++ b/src/element.ts @@ -49,7 +49,10 @@ function phoneToE164(value: string): string | null { } } -const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode'] as const; +/** Public smooth-operator repo — the "powered by" header tag + footer link here. */ +const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator'; + +const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const; /** * Inline SVG icons (static, trusted strings — never interpolated with user data). @@ -204,6 +207,7 @@ export class SmoothAgentChatElement extends HTMLElement { greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined, connectionErrorMessage: this.overrides.connectionErrorMessage, startOpen: this.overrides.startOpen ?? this.hasAttribute('start-open'), + hideBranding: this.overrides.hideBranding ?? this.hasAttribute('hide-branding'), examplePrompts: this.overrides.examplePrompts, requireName: this.overrides.requireName, requireEmail: this.overrides.requireEmail, @@ -282,7 +286,11 @@ export class SmoothAgentChatElement extends HTMLElement { ${escapeHtml(resolved.agentName)} - powered by smooth-operator + ${ + resolved.hideBranding + ? '' + : `powered by smooth-operator` + } ` : `
${monogram}
@@ -329,7 +337,15 @@ export class SmoothAgentChatElement extends HTMLElement {
`; - const restoreLink = this.allowChatRestore ? ` · ` : ''; + // Footer: optional "powered by" branding (hidden by hide-branding) and an + // optional "Restore my chats" affordance. The " · " separator only appears + // when both are present, and the footer is omitted entirely when neither is. + const brandingHtml = resolved.hideBranding + ? '' + : `powered by smooth‑operator`; + const restoreBtn = this.allowChatRestore ? `` : ''; + const footerInner = [brandingHtml, restoreBtn].filter(Boolean).join(' · '); + const footerHtml = footerInner ? `` : ''; const chatHtml = `
@@ -338,7 +354,7 @@ export class SmoothAgentChatElement extends HTMLElement { - + ${footerHtml} `; const container = document.createElement('div'); diff --git a/src/styles.ts b/src/styles.ts index a17cc70..aa44375 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -239,7 +239,8 @@ ${ } .close:hover { background: color-mix(in srgb, var(--sac-text) 12%, transparent); transform: translateY(1px); } .close svg { width: 16px; height: 16px; opacity: .8; } -.powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; } +.powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; color: inherit; text-decoration: none; } +.powered:hover { opacity: .85; text-decoration: underline; } .header-sep { height: 1px; margin: 0 16px; background: linear-gradient(90deg, transparent, var(--sac-border), transparent); } /* Full-page header: taller, logo-led, no close. */ @@ -511,6 +512,8 @@ ${ color: color-mix(in srgb, var(--sac-text) 38%, transparent); } .footer b { font-weight: 600; color: color-mix(in srgb, var(--sac-text) 55%, transparent); } +.footer a { color: inherit; text-decoration: none; } +.footer a:hover { text-decoration: underline; } /* ─────────────────── Pre-chat identity form ───────────────────────── */ .prechat { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 18px; padding: 22px 20px; }