# Website chat — `@sonordev/site-kit/chat`

Echo's chat launcher and conversation on your site, configured in Sonor
(Messages → Chat settings). AI answers, live handoff to your team, and an
offline form when nobody's around.

## Usage

`SiteKitLayout` mounts it for you, after the page goes idle:

```tsx
<SiteKitLayout>{children}</SiteKitLayout>                          // chat on (default)
<SiteKitLayout chat={{ offsetBottom: '88px' }}>{children}</SiteKitLayout>
<SiteKitLayout chat={false}>{children}</SiteKitLayout>             // no chat
```

On a site without `SiteKitLayout`, mount `SiteChat` the same way:

```tsx
'use client'
import { SiteChat } from '@sonordev/site-kit/chat'

export function Chat() {
  return <SiteChat position="bottom-right" />
}
```

`SiteChat` waits for the browser to go idle, skips cross-origin frames and
localhost (like analytics), and downloads the chat widget only when it
renders. `ChatWidget` is the widget itself, for a page that resolves those
choices on its own.

## Props

```ts
interface SiteChatProps {
  apiUrl?: string                            // Default: from SiteKitLayout, else https://api.sonor.io
  apiKey?: string                            // Default: from SiteKitLayout
  projectId?: string                         // For chat routing. Resolved from the key when absent
  position?: 'bottom-right' | 'bottom-left'  // Default: 'bottom-right'
  offsetBottom?: string | number             // Default: '20px'. See "Launcher placement"
  zIndex?: number                            // Default: 9999. See "Stacking"
  allowInFrame?: boolean                     // Show inside a cross-origin frame. Default: false
  allowLocalhost?: boolean                   // Show on localhost. Default: false
}
```

Through `SiteKitLayout`, the placement options go in `chat={{ ... }}`. The
deprecated `engage={{ ... }}` still works through 7.x.

## The chat switch

Echo follows the project's **Show the chat widget** switch in Sonor
(Messages → Chat settings). A project that has never saved chat settings
counts as on, so Echo is on by default and only an owner who switches it off
hides it. It also needs the project's **Website chat** module on (Project
Settings); without it Sonor answers off, since the chat has nowhere to take a
visitor's message.

- The launcher appears once `GET /engage/widget/config` answers. Nothing
  renders before that, so a switched-off site never flashes a launcher, and
  it never starts Echo's availability polling.
- If the config can't be fetched, the launcher stays hidden. The chat can't
  run without the API anyway.
- `chat={false}` in code still turns chat off whatever the switch says. Code
  can turn Echo off; it can't force it on over the owner's switch.

## Liquid Glass (6.1.0)

Echo's launcher and chat window are made of the kit's shared Liquid Glass
recipe (`src/shared/glass.tsx`), the same material as the mobile CTA bar
(`@sonordev/site-kit/cta-bar`), so the two always match.

- **Launcher:** brand-tinted glass (86% brand over a blurred backdrop) with a
  specular rim. The icon turns dark on a light brand colour; it used to stay
  white and disappear.
- **Chat window:** a glass panel (86% tint, 28px blur) that grows out of the
  launcher's corner. The header is part of the sheet, washed with the brand
  at the top, and the brand sits on the avatar tile, the visitor's bubbles,
  the send button and the launcher.
- **Bubbles and the composer stay near-opaque** (94%). The glass is depth,
  never the surface text reads against.
- **Brand text always reads (6.1.2).** Where the brand is text on the panel
  (quick-action chips, the phone link, link buttons, suggestion chips, "Talk
  to a person"), Echo uses the brand as-is when it clears WCAG AA against the
  panel, and otherwise pulls it toward black (light panel) or white (dark
  panel) just far enough to clear it: `#d4af37` gold becomes `#887023`. Fills
  (launcher, avatar, the visitor's bubbles, buttons) keep the raw brand. One
  helper decides this, `src/chat/brand-color.ts`; route any new brand
  foreground through it. `--sk-primary` can be hex, `rgb()` or `hsl()`.
- **Fields are 16px.** iOS Safari zooms the whole page into any focused field
  under 16px; the chat input and the inline Echo forms were 13.5-14px.
- **Fallbacks:** no `backdrop-filter`, reduced transparency, or more contrast
  all get the same surfaces solid. Reduced motion skips the open animation.

Tune it with the shared tokens, from your own stylesheet:

```css
:root {
  --sk-glass-panel-opacity: 92%;   /* denser chat window (default 86%) */
  --sk-glass-brand-opacity: 100%;  /* solid brand launcher (default 86%) */
  --sk-glass-tint: #0b0b0c;        /* dark glass; pair with --sk-text-primary */
}
```

`--sk-glass-panel-opacity: 100%` gives the pre-6.1 solid window back, with the
glass header.

## Launcher placement

The Echo launcher is a 60px circle, fixed 20px from the side and 20px above
the bottom edge. Its placement is an inline style, so a stylesheet can't move
it without `!important`. Don't write that override; declare the offset instead.

**A fixed offset, every page:** pass `offsetBottom`.

```tsx
<SiteKitLayout chat={{ offsetBottom: '88px' }}>{children}</SiteKitLayout>
```

Any CSS length works (`'5.5rem'`, `'calc(4rem + 8px)'`); a number is pixels.
The device's safe-area inset is added on top, so pass the clearance you want
above your own UI, not the inset.

**An offset that depends on the page or the breakpoint:** set the
`--sk-echo-offset-bottom` custom property from your stylesheet. The launcher
is portalled to `<body>`, so a declaration on `body` or `:root` reaches it,
and the property wins over `offsetBottom`. This clears a sticky mobile bar
only on the pages that render one:

```css
@media (max-width: 1023.98px) {
  body:has(.sticky-cta) {
    --sk-echo-offset-bottom: 5.5rem;
  }
}
```

Pages without the bar, desktop, and any browser without `:has()` keep the
default. No `!important`, no selector on the kit's markup.

**Using the kit's `<CtaBar>`?** Write nothing. It sets
`--sk-echo-offset-bottom` itself while it's on screen, below its breakpoint,
and the launcher glides up and back down as the bar shows and hides. Delete
any rule like the one above that was written for a hand-rolled bar.

**The popup follows.** It opens 10px above the launcher and keeps 30px clear
of the top edge, wherever the offset puts the launcher. (An override that
moved only the `<button>` left the popup behind, with the launcher over its
input row.)

The resolved position is:

```
launcher bottom = offset + env(safe-area-inset-bottom) + var(--sk-vv-layout-gap, 0px)
popup bottom    = launcher bottom + 70px
popup maxHeight = 100dvh - launcher bottom - 100px
```

### Stacking

`zIndex` is the layer the chat launcher sits on, with the chat window one
layer beneath it. Through `SiteKitLayout` the same number also stacks the
site's popups, banners and toasts. The default is 9999, above almost anything
a site draws, so lower it when your own fixed UI (a mobile menu, a cookie
banner) has to cover the chat:

```tsx
<SiteKitLayout chat={{ zIndex: 40 }}>{children}</SiteKitLayout>
```

```
launcher z-index = zIndex (default 9999)
popup z-index    = zIndex - 1
```

At `zIndex` 0 or below the chat window shares the launcher's layer instead,
because -1 would put it behind the page's own content. The launcher still
paints on top.

### Layout vs visual viewport on phones

On a phone the layout viewport can be taller than what the visitor can see
(browser chrome, in-app browsers, pinch zoom), and `position: fixed` is
measured against the layout viewport, so a bottom-fixed launcher can sit
below the fold. Mount `VisualViewportGap` to publish the difference as
`--sk-vv-layout-gap` on `<html>`. The launcher and popup already read it, and
your own fixed bottom UI can too:

```tsx
import { VisualViewportGap } from '@sonordev/site-kit/client'

<SiteKitLayout>
  <VisualViewportGap />
  {children}
</SiteKitLayout>
```

```css
.mobile-cta {
  bottom: calc(24px + env(safe-area-inset-bottom, 0px) + var(--sk-vv-layout-gap, 0px));
}
```

It's opt-in: while a field has focus the gap grows to the keyboard's height,
so everything that reads it rides above the keyboard. `useVisualViewportGap()`
is the hook form for an existing client component.

## Chat config

AI mode (Echo), live mode, and hybrid (AI with a human handoff). `ChatWidget` takes these as `config`; Sonor sends the rest from Chat settings:

```ts
interface ChatConfig {
  position: 'bottom-right' | 'bottom-left'
  offsetBottom?: string | number   // See "Launcher placement"
  zIndex?: number                  // Default: 9999. See "Stacking"
  mode: 'ai' | 'live' | 'hybrid'
  buttonColor?: string
  aiSettings?: {
    skillId?: string
    handoffToLive?: boolean
    handoffKeywords?: string[]
  }
  operatingHours?: { ... }
  offlineMode?: 'form' | 'ai' | 'message'
  offlineFormSlug?: string
}
```

## Popups

Popups, banners and toasts are their own module now:
[Popups and banners](https://sonor.dev/site-kit/popups) (`@sonordev/site-kit/website/popups`).

## `@sonordev/site-kit/engage` (deprecated)

Engage was retired in Sonor. Its entry stays through 7.x so old imports keep
building: `ChatWidget` and the chat types re-export from here, and
`EngageWidget` draws `SiteChat` and `SitePopups`. Engage Studio's renderer
(`DesignRenderer`) is gone; popups render from blocks. Import
`@sonordev/site-kit/chat` and `@sonordev/site-kit/website/popups` instead.
