npm package: @pindai-ai/chat-widget
Description: Modern, accessible chat widget for Pindai.ai
Author: Pindai.ai
Latest version: 3.0.12

# Pindai Chat Widget

> Modern, accessible, embeddable chat widget — seamlessly integrated with **Pindai Agent-API** or any generic HTTP backend.

[![npm version](https://img.shields.io/npm/v/@pindai-ai/chat-widget.svg)](https://www.npmjs.com/package/@pindai-ai/chat-widget)
[![npm downloads](https://img.shields.io/npm/dm/@pindai-ai/chat-widget.svg)](https://www.npmjs.com/package/@pindai-ai/chat-widget)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)

---

## Table of Contents

- [Quick Start](#quick-start)
- [Features](#features)
- [Installation](#installation)
- [Configuration Options](#configuration-options)
  - [Mode (Agent-API vs Webhook)](#mode-agent-api-vs-webhook)
  - [Display](#display)
  - [Branding & Avatar](#branding--avatar)
  - [Theme & Layout](#theme--layout)
  - [Footer](#footer)
  - [Bubble](#bubble)
  - [Quick Replies](#quick-replies)
  - [File Upload](#file-upload)
  - [Streaming](#streaming)
  - [Action Indicators](#action-indicators)
  - [Message History](#message-history)
  - [Notifications](#notifications)
  - [Session](#session)
  - [Visitor Info](#visitor-info)
  - [Technical / Advanced](#technical--advanced)
- [Agent-API Authentication](#agent-api-authentication)
- [Remote Config (Live Dashboard Sync)](#remote-config-live-dashboard-sync)
- [SSE Streaming](#sse-streaming)
- [Human-Agent Handoff](#human-agent-handoff)
- [Bubble Behavior In Depth](#bubble-behavior-in-depth)
- [Markdown Rendering](#markdown-rendering)
- [Dark Mode](#dark-mode)
- [Customization Examples](#customization-examples)
- [Backend API Reference](#backend-api-reference)
- [Accessibility](#accessibility)
- [Browser Support](#browser-support)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
- [Changelog](#changelog)

---

## Quick Start

### Option A — Pindai Agent-API (recommended)

Get your `agentId`, `embedSecret`, and `apiBaseUrl` from the Pindai dashboard under **Chatbot → Embed**.

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.css">
<script src="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.js"></script>

<script>
  PindaiChatWidget.init({
    agentId: 'YOUR_AGENT_UUID',
    embedSecret: 'YOUR_EMBED_SECRET_HEX',
    apiBaseUrl: 'https://api.yourcompany.com',
    title: 'Customer Support',
    locale: 'en',
    showBranding: false,
  });
</script>
```

### Option B — Generic Webhook (n8n, Dify, Express, FastAPI, etc.)

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.css">
<script src="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.js"></script>

<script>
  PindaiChatWidget.init({
    webhookUrl: 'https://your-backend.com/webhook/chat',
    title: 'Support',
    locale: 'en',
    showLogo: false,
  });
</script>
```

---

## Features

| Feature | Details |
|---------|---------|
| **Pindai Agent-API integration** | HMAC-SHA256 auth, remote config sync, SSE streaming, human-agent handoff |
| **Generic webhook** | Works with any HTTP POST endpoint (n8n, Dify, FastAPI, Express…) |
| **Bubble text** | Speech bubble above the launcher — single message or rotating carousel |
| **Avatar thumbnails** | Per-message avatar shown next to AI replies |
| **Action indicators** | Cycling "Thinking… → Performing action… → Working on it…" typing label |
| **Markdown rendering** | Bold, italic, code blocks, links, lists in AI messages |
| **Dark / light / auto theme** | Follows system preference or locked via config |
| **File uploads** | PDF, images, Office docs — up to 10 MB, 5 files per message |
| **SSE streaming** | Real-time incremental response rendering |
| **Quick replies** | Configurable suggested responses below the input |
| **Human-agent handoff** | Auto-polls for human replies and shows join notification |
| **Message history** | localStorage persistence with configurable expiry |
| **Offline detection** | Graceful network status handling with retry |
| **Unread badge** | Count of unread messages on launcher |
| **Left/right alignment** | `bottom-right` (default) or `bottom-left` launcher position |
| **Custom footer** | Additional text/links appended to the "Powered by Pindai.ai" branding |
| **WCAG 2.2 AA** | Full keyboard navigation, ARIA, 4.5:1 contrast |
| **Zero dependencies** | ~14 KB gzipped, pure ES2020, no external runtime libraries |
| **Backward compatible** | Existing `webhookUrl` integrations work unchanged |

---

## Installation

### Via CDN (recommended for most sites)

```html
<!-- CSS (load in <head>) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.css">

<!-- JS (load before </body> or with defer) -->
<script src="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.js"></script>
```

Pin to a specific version to avoid unexpected updates:

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3.0.2/dist/pindai-chat-widget.css">
<script src="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3.0.2/dist/pindai-chat-widget.js"></script>
```

### Via npm

```bash
npm install @pindai-ai/chat-widget
```

```javascript
import '@pindai-ai/chat-widget/dist/pindai-chat-widget.css';
import PindaiChatWidget from '@pindai-ai/chat-widget';

PindaiChatWidget.init({ ... });
```

---

## Configuration Options

### Mode (Agent-API vs Webhook)

Exactly **one** of the following groups is required:

#### Agent-API mode

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `agentId` | string | Yes | Agent UUID from the Pindai dashboard |
| `embedSecret` | string | Yes | 32-byte hex embed secret from the dashboard |
| `apiBaseUrl` | string | Yes | Base URL of your agent-api instance (no trailing slash) |

#### Generic webhook mode (legacy)

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `webhookUrl` | string | Yes | Any HTTP POST endpoint that accepts FormData |

---

### Display

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | string | `'widget'` | `'widget'` — floating launcher button; `'fullscreen'` — inline full-page chat |
| `locale` | string | `'id'` | UI language: `'id'` (Indonesian) or `'en'` (English) |
| `title` | string | Localized | Chat header title text |
| `initialMessage` | string | Localized | First AI message shown when the chat opens |

---

### Branding & Avatar

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `avatarUrl` | string | `null` | URL of a circular avatar image. Shown in the header and next to each AI message bubble. Recommended: 64×64 px or larger |
| `launcherIconUrl` | string | Chat SVG | Custom launcher button icon. Supports PNG, JPG, GIF, WebP, and SVG |
| `accentColor` | string | `'#2563eb'` | Primary accent color — sets `launcherColor` and `sendButtonColor` unless overridden |
| `launcherColor` | string | `accentColor` | Background color of the launcher button |
| `sendButtonColor` | string | `accentColor` | Background color of the send button |
| `logoUrl` | string | Pindai logo | Header logo URL (only relevant when `showLogo: true`) |
| `showLogo` | boolean | `true` | Show/hide the header logo. Set to `false` when using `avatarUrl` |

> **Tip:** Use `avatarUrl` for a branded avatar (overrides the logo entirely). Use `launcherIconUrl` to set a distinct icon on the launcher button independently.

---

### Theme & Layout

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `theme` | string | `'light'` | `'light'`, `'dark'`, or `'auto'` (follows `prefers-color-scheme`) |
| `buttonAlignment` | string | `'bottom-right'` | `'bottom-right'` or `'bottom-left'` |

---

### Footer

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `showBranding` | boolean | `true` | Show "Powered by Pindai.ai" link in the footer |
| `customFooter` | string | `null` | Extra text appended after the branding. Supports `[text](url)` Markdown links |

**Footer rendering examples:**

| `showBranding` | `customFooter` | Renders as |
|---------------|----------------|-----------|
| `true` | `null` | `Powered by Pindai.ai` |
| `true` | `'Help Center'` | `Powered by Pindai.ai | Help Center` |
| `true` | `'[Help](https://help.example.com)'` | `Powered by Pindai.ai | Help` (linked) |
| `false` | `'My Company'` | `My Company` |
| `false` | `null` | *(no footer)* |

---

### Bubble

The speech bubble appears above the launcher button to proactively engage visitors.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `bubbleText` | string | `null` | Shorthand for a single bubble message. Equivalent to `bubbleMessages: ['your text']` |
| `bubbleMessages` | string[] | `null` | One or more messages. When multiple are provided they rotate automatically. Takes priority over `bubbleText` |
| `bubbleDelay` | number | `3000` | Milliseconds before the bubble first appears after page load |
| `bubbleInterval` | number | `5000` | Milliseconds between each rotating message (only used with multiple `bubbleMessages`) |
| `showBubbleOnce` | boolean | `true` | Bubble lifecycle mode — see [Bubble Behavior In Depth](#bubble-behavior-in-depth) |

---

### Quick Replies

Suggested response buttons shown below the input field when the chat first opens.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `showQuickReplies` | boolean | `false` | Show/hide quick reply buttons |
| `quickReplies` | string[] | Localized | Up to 4 suggested responses |

> **Note:** `showQuickReplies` defaults to `false`. Enable it explicitly and provide `quickReplies` tailored to your agent's use case.

---

### File Upload

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enableFileUpload` | boolean | `true` | Enable the file attachment button |
| `allowedFileTypes` | string[] | See below | Accepted MIME types |
| `maxFileSize` | number | `10485760` | Maximum file size in **bytes** (default: 10 MB) |
| `maxFiles` | number | `5` | Maximum number of files per message |

**Default allowed MIME types:**
```javascript
[
  'image/jpeg', 'image/png', 'image/gif', 'image/webp',
  'application/pdf',
  'application/msword',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.ms-excel',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]
```

> **Note:** When files are attached, SSE streaming is automatically disabled and a regular multipart POST is used instead.

---

### Streaming

*Agent-API mode only.*

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enableStreaming` | boolean | `true` | Stream AI responses via SSE for a real-time typing effect. Set `false` to use standard POST/response |

---

### Action Indicators

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `showActionIndicators` | boolean | `true` | Show a cycling text label in the typing indicator while the AI is processing |

When enabled, the label cycles through:
- "Thinking…"
- "Performing action…"
- "Working on it…"
- "Almost there…"

---

### Message History

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enableHistory` | boolean | `true` | Persist messages across page reloads via localStorage |
| `maxHistoryItems` | number | `50` | Maximum number of stored messages |
| `sessionExpiryHours` | number | `24` | Hours of inactivity before the session is reset and history cleared |

---

### Notifications

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enableNotifications` | boolean | `true` | Show unread message count badge on the launcher |
| `enableSound` | boolean | `false` | Play a sound on new messages *(reserved for future use)* |

---

### Session

In Agent-API mode a `sessionId` is automatically generated per visitor and stored in `localStorage`. It is included in every request so the API can maintain conversation context.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `sessionExpiryHours` | number | `24` | Hours of inactivity before auto-resetting the session |

---

### Visitor Info

*Agent-API mode only.* Forwarded to the API for CRM enrichment or handoff context.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `visitorName` | string | `null` | Visitor's display name |
| `visitorEmail` | string | `null` | Visitor's email address |

---

### Technical / Advanced

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `maxRetries` | number | `3` | Retry attempts on 5xx / network errors |
| `retryDelay` | number | `1000` | Base delay in ms between retries (multiplied per attempt) |
| `requestTimeout` | number | `30000` | Request timeout in ms |
| `rateLimit` | number | `5` | Max messages allowed per `rateLimitWindow` |
| `rateLimitWindow` | number | `60000` | Rate limit window in ms |
| `pollingInterval` | number | `3000` | Human-agent polling interval in ms |
| `pollingUrl` | string | Auto | *Legacy webhook only.* In Agent-API mode the polling URL is derived automatically |

---

## Agent-API Authentication

Every request from the widget is authenticated with a **per-session HMAC-SHA256 token** — no cookies, no server-side session storage required.

**How it works:**

1. The Pindai dashboard generates a unique `embedSecret` (32-byte hex) per agent
2. When the widget loads, it generates a random `sessionId` (stored in `localStorage`)
3. The widget computes the token using native `crypto.subtle`:
   ```
   embedToken = HMAC-SHA256(key: embedSecret, data: "{agentId}:{sessionId}")
   ```
4. The token is included in every POST request and SSE query string
5. The agent-api server recomputes the token and rejects requests that don't match

**Security properties:**
- Token is scoped to the session — replaying it from a different session is rejected
- `embedSecret` never leaves the server — the widget only receives the computed token
- Token is re-generated on page load, not stored in localStorage

**To rotate the secret** (invalidates all existing tokens instantly): click "Rotate Secret" in the dashboard Embed tab or call `POST /v1/agents/{agentId}/rotate-secret`.

---

## Remote Config (Live Dashboard Sync)

In Agent-API mode, the widget fetches the agent's current appearance and behavior config from the API on every page load:

```
GET /v1/chat/{agentId}/config
```

This means **changes made in the Pindai dashboard take effect immediately** without re-pasting the embed snippet. The following properties are synced from the dashboard:

- Title, initial message, locale
- Accent color, theme, button alignment
- Avatar URL, launcher icon URL
- Bubble text, bubble messages, bubble delay/interval
- Show branding, custom footer, show action indicators
- File upload settings (enable, allowed types, max size, max files)
- Streaming toggle
- Quick replies (enable toggle + list)

**Priority rules:**
- Remote config values override local init options **except** when a local option is explicitly provided and marked as user-controlled (e.g., `showBubbleOnce` — if you pass it explicitly in `init()`, the remote value is ignored)
- Remote config falls back silently — if the request fails, the widget loads with local init options

---

## SSE Streaming

When `enableStreaming: true` (default in Agent-API mode), the widget connects to the SSE endpoint and renders the AI response token-by-token as it arrives:

```
GET /v1/chat/{agentId}/stream?message=...&sessionId=...&embedToken=...
```

**SSE event format:**
```
data: {"delta": "Hello"}
data: {"delta": " there!"}
data: {"type": "done", "status": "active"}
```

The widget renders each `delta` into the message bubble in real time, switches from plain text to full Markdown rendering when the `done` event arrives.

**Automatic fallback:** When files are attached, streaming is disabled and a regular `POST /v1/chat/{agentId}/message` (multipart FormData) is used instead — SSE does not support request bodies.

---

## Human-Agent Handoff

When a human agent takes over a conversation (status changes from `active` to `pending` or `assigned`):

1. Widget detects the status change in the API response
2. Starts polling `GET /v1/chat/{agentId}/messages?since=<timestamp>&sessionId=...&embedToken=...` every `pollingInterval` ms
3. Renders new messages from human agents in the chat window
4. Displays a localised "A team member has joined the conversation." notification
5. Stops polling when status returns to `active` or `resolved`

Configure the polling frequency with `pollingInterval` (default: `3000` ms).

---

## Bubble Behavior In Depth

The bubble is a speech balloon that appears above the launcher to proactively invite visitors to chat.

### Lifecycle with `showBubbleOnce: true` (default)

```
Page loads → wait bubbleDelay ms → bubble appears
User dismisses (× button) OR opens widget → _bubbleDismissed = true saved to localStorage
Next page load → bubble never appears again for this visitor
```

Use this for a single welcome message that you don't want to nag repeat visitors with.

### Lifecycle with `showBubbleOnce: false`

```
Page loads → wait bubbleDelay ms → bubble appears
User opens widget → bubble hides (while chat is open)
User closes widget → wait bubbleDelay ms → bubble reappears
```

Use this for persistent proactive messaging — ideal for demo pages and high-intent landing pages.

### Rotating messages

Pass an array to `bubbleMessages` (or a single `bubbleText`). When more than one message is provided:

```javascript
PindaiChatWidget.init({
  bubbleMessages: [
    'Need help? We reply instantly! 👋',
    'Ask me about our pricing',
    'Get started in 2 minutes',
  ],
  bubbleDelay: 2000,     // first message appears after 2s
  bubbleInterval: 5000,  // rotates every 5s with a fade animation
  showBubbleOnce: false, // always comes back after closing
});
```

### Priority

- `bubbleMessages` (array) takes priority over `bubbleText` (string shorthand)
- Remote config `bubble_messages` (array) takes priority over `bubble_text` (scalar) from the dashboard
- If `showBubbleOnce` is explicitly passed in `init()`, remote config cannot override it

---

## Markdown Rendering

AI message content is rendered as Markdown. Supported syntax:

| Syntax | Renders as |
|--------|-----------|
| `**bold**` or `__bold__` | **bold** |
| `*italic*` or `_italic_` | *italic* |
| `` `inline code` `` | `inline code` |
| ` ```\ncode block\n``` ` | Syntax-highlighted block |
| `[text](url)` | Hyperlink (opens in new tab) |
| `- item` / `* item` | Unordered list |
| `1. item` | Ordered list |
| `---` | Horizontal rule |
| `> quote` | Blockquote |

> **Security:** User messages are always rendered as plain text (`textContent`) to prevent XSS. Only AI/agent messages use `innerHTML` with the Markdown renderer.

---

## Dark Mode

Set `theme: 'dark'` for a fixed dark theme, or `theme: 'auto'` to follow the visitor's OS preference:

```javascript
PindaiChatWidget.init({
  theme: 'auto', // respects prefers-color-scheme
  accentColor: '#4a9eff',
});
```

The widget applies `data-pindai-theme="dark"` on `<html>` and all dark-mode styles are scoped under that selector — so the widget won't affect the rest of your page's styling.

---

## Customization Examples

### Full Agent-API setup with branding

```javascript
PindaiChatWidget.init({
  agentId: 'YOUR_AGENT_UUID',
  embedSecret: 'YOUR_SECRET_HEX',
  apiBaseUrl: 'https://api.yourcompany.com',

  // Display
  locale: 'en',
  title: 'Vika — Customer Support',
  initialMessage: 'Hello! How can I help you today?',

  // Branding
  avatarUrl: 'https://yourcompany.com/avatar.png',
  launcherIconUrl: 'https://yourcompany.com/launcher.svg',
  accentColor: '#0891b2',

  // Theme
  theme: 'auto',
  buttonAlignment: 'bottom-right',

  // Footer
  showBranding: true,
  customFooter: '[Help Center](https://help.yourcompany.com)',

  // Bubble — always comes back after closing
  bubbleText: 'Questions? I\'m here to help!',
  bubbleDelay: 3000,
  showBubbleOnce: false,

  // Features
  enableStreaming: true,
  enableFileUpload: true,
  showActionIndicators: true,
  showQuickReplies: true,
  quickReplies: ['Pricing', 'Get started', 'Talk to sales', 'View docs'],
});
```

### Persistent rotating bubble (demo / landing page)

```javascript
PindaiChatWidget.init({
  agentId: 'YOUR_AGENT_UUID',
  embedSecret: 'YOUR_SECRET_HEX',
  apiBaseUrl: 'https://api.yourcompany.com',
  bubbleMessages: [
    'Questions about our pricing? Ask me! 💬',
    'Need a demo? I can schedule one right now.',
    'Try asking: "How does this work?"',
  ],
  bubbleDelay: 2000,
  bubbleInterval: 5000,
  showBubbleOnce: false,  // reappears after every close
});
```

### Single dismiss-once bubble (standard site widget)

```javascript
PindaiChatWidget.init({
  agentId: 'YOUR_AGENT_UUID',
  embedSecret: 'YOUR_SECRET_HEX',
  apiBaseUrl: 'https://api.yourcompany.com',
  bubbleText: 'Hi there! Need any help? 👋',
  bubbleDelay: 4000,
  showBubbleOnce: true,  // won't show again after first dismiss/open
});
```

### Dark theme, left alignment, no uploads

```javascript
PindaiChatWidget.init({
  webhookUrl: 'https://your-backend.com/webhook/chat',
  theme: 'dark',
  buttonAlignment: 'bottom-left',
  accentColor: '#7c3aed',
  enableFileUpload: false,
  showQuickReplies: false,
  showBranding: false,
  showLogo: false,
});
```

### Minimal — webhook only, no extras

```javascript
PindaiChatWidget.init({
  webhookUrl: 'https://your-backend.com/webhook/chat',
  title: 'Support',
  locale: 'en',
  enableFileUpload: false,
  showQuickReplies: false,
  showBranding: false,
  showLogo: false,
  enableNotifications: false,
  showActionIndicators: false,
});
```

### Pre-fill visitor info (Agent-API mode)

```javascript
// After user logs in, pass their info to the widget
PindaiChatWidget.init({
  agentId: 'YOUR_AGENT_UUID',
  embedSecret: 'YOUR_SECRET_HEX',
  apiBaseUrl: 'https://api.yourcompany.com',
  visitorName: currentUser.name,
  visitorEmail: currentUser.email,
});
```

---

## Backend API Reference

### Agent-API endpoints (auto-used in agent-api mode)

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/v1/chat/{agentId}/config` | GET | Fetch remote appearance/behavior config |
| `/v1/chat/{agentId}/message` | POST | Send a user message (with optional files) |
| `/v1/chat/{agentId}/stream` | GET (SSE) | Stream AI response incrementally |
| `/v1/chat/{agentId}/messages` | GET | Poll for human-agent replies |

#### POST `/v1/chat/{agentId}/message`

**Request** — `multipart/form-data`:

| Field | Required | Description |
|-------|----------|-------------|
| `sessionId` | Yes | Widget-generated session identifier |
| `message` | Yes | User message text |
| `embedToken` | Yes | HMAC-SHA256 embed token |
| `visitor_name` | No | Pre-filled visitor name |
| `visitor_email` | No | Pre-filled visitor email |
| `file0` … `fileN` | No | Uploaded file attachments |

**Response** — JSON:
```json
{
  "response": "AI response text (Markdown supported)",
  "conversation_id": "uuid",
  "status": "active"
}
```

`status` values: `active`, `pending` (waiting for human), `assigned` (human joined), `resolved`.

#### GET `/v1/chat/{agentId}/stream`

**Query params:** `message`, `sessionId`, `embedToken`

**SSE events:**
```
data: {"delta": "Hello"}
data: {"delta": " world!"}
data: {"type": "done", "status": "active"}
data: {"type": "error", "message": "..."}
```

#### GET `/v1/chat/{agentId}/messages`

**Query params:** `since` (ISO timestamp), `sessionId`, `embedToken`

**Response:**
```json
{
  "messages": [
    { "role": "assistant", "content": "Hi, this is Sarah from support.", "created_at": "..." }
  ],
  "status": "assigned"
}
```

### Generic webhook (legacy mode)

**Request** (FormData POST):
```
sessionId=web-session-...&message=Hello&file0=<File>
```

**Response** (JSON — any of):
```json
{ "response": "AI response text" }
{ "output": "AI response text" }
{ "text": "AI response text" }
{ "message": "AI response text" }
```

---

## Accessibility

WCAG 2.2 Level AA compliant:

- Full keyboard navigation: **Tab** / **Shift+Tab** to navigate, **Enter** to send, **Escape** to close
- All interactive elements have descriptive `aria-label` attributes
- Chat message list uses `aria-live="polite"` for screen reader announcements
- Focus is trapped within the open chat dialog (focus loop)
- Color contrast ratios meet or exceed 4.5:1 for all text
- All touch targets are at minimum 44×44 px
- Respects `prefers-reduced-motion` (animations disabled when set)
- Tested with VoiceOver (macOS/iOS) and NVDA (Windows)

---

## Browser Support

| Browser | Minimum Version |
|---------|----------------|
| Chrome / Edge | 90+ |
| Firefox | 88+ |
| Safari | 14+ |
| iOS Safari | 14+ |
| Android Chrome | 90+ |

**Required browser APIs:** `fetch`, `EventSource` (SSE), `crypto.subtle` (HMAC), `FormData`, `localStorage`

All are available natively — no polyfills needed for modern browsers.

---

## Development

```bash
git clone https://github.com/PindaiAI/pindai-chat-widget.git
cd pindai-chat-widget
npm install
npm run dev      # Dev server at http://localhost:5173 (hot reload)
npm run build    # Production build → dist/
npm run preview  # Preview production build locally
```

**Project structure:**

```
pindai-chat-widget/
├── src/
│   ├── main.js      # PindaiChatWidget class — all widget logic
│   ├── style.css    # Design system + all component styles
│   └── i18n.js      # Indonesian + English translation strings
├── dist/            # Built output (committed for CDN delivery)
│   ├── pindai-chat-widget.js       # ES module build
│   ├── pindai-chat-widget.umd.js   # UMD build (for <script> tags)
│   └── pindai-chat-widget.css      # Compiled stylesheet
├── index.html       # Interactive demo page (dev)
├── vite.config.js   # Vite build configuration
└── package.json
```

**Adding a translation:**

Edit `src/i18n.js` and add a new locale block following the `en` or `id` pattern. Then pass `locale: 'your-locale'` in `init()`.

---

## Troubleshooting

### Bubble text not appearing

1. **Already dismissed:** If `showBubbleOnce: true` (default) and the user previously opened or dismissed the widget, the bubble won't show again. Use `showBubbleOnce: false` for pages where the bubble should always appear.
2. **Insufficient delay:** Try increasing `bubbleDelay` — if the user scrolls quickly, they may miss a 1–2s bubble.
3. **Remote config override:** In Agent-API mode the dashboard's `show_bubble_once` setting can override local config. Pass `showBubbleOnce` explicitly in `init()` to lock it locally.

### Widget not appearing

1. Check the browser console for JavaScript errors (F12)
2. Verify `agentId` / `embedSecret` / `apiBaseUrl` or `webhookUrl` is correct
3. Ensure the script is loaded and `PindaiChatWidget.init()` is called
4. Check for Content Security Policy (CSP) restrictions on your page

### CORS errors

Add the widget's origin to your API's CORS `allow_origins`. In development, add `http://localhost:5173`. For the Pindai agent-api, `/v1/chat/*` endpoints already allow `*` (wildcard) to support embedded widgets on any domain.

### Streaming not working

1. Verify your agent-api supports `GET /v1/chat/{agentId}/stream`
2. Check NGINX/reverse proxy: add `proxy_buffering off; proxy_cache off;` for SSE routes
3. Disable as fallback: `enableStreaming: false`

### Messages appear but avatar is broken

Ensure `avatarUrl` points to a publicly accessible URL (no auth required). If using MinIO, verify the `avatars/` prefix has public download policy (`mc anonymous set download`).

### Logo shows broken image

Set `showLogo: false` (recommended when using `avatarUrl`) or provide a valid `logoUrl`:
```javascript
PindaiChatWidget.init({ ..., showLogo: false });
```

### CDN cache stale after publish

Force-purge the jsDelivr cache:
```bash
curl https://purge.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.js
curl https://purge.jsdelivr.net/npm/@pindai-ai/chat-widget@3/dist/pindai-chat-widget.css
```

Or pin to a specific version to opt out of the cache entirely:
```html
<script src="https://cdn.jsdelivr.net/npm/@pindai-ai/chat-widget@3.0.2/dist/pindai-chat-widget.js"></script>
```

---

## Changelog

### v3.0.2

**Bug fixes:**
- **Bubble text**: `bubble_text` from remote config now correctly populates `bubbleMessages` (was only updating an internal scalar, never reaching the launcher)
- **`showBubbleOnce` priority**: local `init()` value now takes priority over remote config — prevents the dashboard default (`true`) from overriding `showBubbleOnce: false` on embed pages
- **Bubble persistence**: with `showBubbleOnce: false`, the bubble now reappears after the chat window is closed (after `bubbleDelay` ms), instead of staying hidden for the remainder of the page session
- **Action indicators**: fixed CSS selector `.n8n-chat-typing-indicator span` colliding with `.n8n-chat-typing-label` — the bounce animation was being applied to the text label. Scoped to `.n8n-chat-typing-dots span`
- **Input background**: added explicit `background-color: #ffffff; color: #111111` on the light-theme input to prevent browser system-theme rendering it as dark grey on certain macOS configurations

**Improvements:**
- Typing indicator now uses `flex-direction: column; align-items: flex-start` for correct vertical stacking of dots + label

### v3.0.1

**New features:**
- **Avatar thumbnails**: per-message avatar shown to the left of each AI reply bubble (`_createAvatarEl`, `_appendToMessageList`)
- **Cycling action indicators**: typing indicator cycles "Thinking… → Performing action… → Working on it… → Almost there…" every 2s via `_startActionLabelCycle` / `_stopActionLabelCycle`
- **Bubble reshow**: `_reshowBubble()` method re-displays bubble with delay after chat closes (when `showBubbleOnce: false`)
- **Widget behavior config fields**: 11 new agent fields synced from dashboard — `enable_file_upload`, `allowed_file_types`, `max_file_size_mb`, `max_files`, `enable_streaming`, `show_quick_replies`, `quick_replies`, `bubble_messages`, `bubble_delay_ms`, `bubble_interval_ms`, `show_bubble_once`
- **Markdown rendering**: AI messages render bold, italic, code blocks, links, lists via `_renderMarkdown()`. User messages stay as plain text for XSS safety

### v3.0.0

**Breaking changes:**
- CDN path uses `@3` tag
- Launcher is now wrapped in `.n8n-chat-launcher-wrapper` (affects custom CSS targeting `.n8n-chat-launcher` for position)

**New features:**
- Pindai Agent-API integration: `agentId`, `embedSecret`, `apiBaseUrl`
- HMAC-SHA256 auth via native `crypto.subtle`
- SSE streaming with real-time rendering
- Human-agent handoff with auto-polling
- Dark theme (`'dark'` / `'auto'`)
- Avatar (`avatarUrl`)
- Bubble text (`bubbleText` / `bubbleMessages`)
- Button alignment (`buttonAlignment: 'bottom-left'`)
- Custom footer (`customFooter`)
- Show branding toggle (`showBranding`)
- Visitor info (`visitorName`, `visitorEmail`)
- Action indicators (`showActionIndicators`)
- Remote config sync from Agent-API dashboard

### v2.0.0–v2.0.4

- Complete UI/UX redesign — mobile-first, WCAG 2.2 AA
- Indonesian localization (`locale: 'id'`)
- File upload, quick replies, notification badge, retry logic, offline detection
- `webhookUrl` (renamed from `n8nUrl`, backward compatible)
- "Powered by Pindai.ai" watermark

### v1.0.0

- Initial release

---

## License

MIT © [Pindai.ai](https://pindai.ai)

---

## Links

- **npm:** [npmjs.com/package/@pindai-ai/chat-widget](https://www.npmjs.com/package/@pindai-ai/chat-widget)
- **CDN:** [jsdelivr.com/package/npm/@pindai-ai/chat-widget](https://www.jsdelivr.com/package/npm/@pindai-ai/chat-widget)
- **GitHub:** [github.com/PindaiAI/pindai-chat-widget](https://github.com/PindaiAI/pindai-chat-widget)
- **Issues:** [github.com/PindaiAI/pindai-chat-widget/issues](https://github.com/PindaiAI/pindai-chat-widget/issues)
- **Pindai.ai:** [pindai.ai](https://pindai.ai)