@sub-zero/cocoa 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,165 @@
1
- # Temporary Holding Version
1
+ # Cocoa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A bottom-right support chat bubble for any web application. Cocoa connects visitors
4
+ to your SubZero chatbot and puts conversations in **Agents → Chatbots**, where your
5
+ team can view, reply, approve agent drafts, and take over.
6
+
7
+ Zero runtime dependencies. Works with vanilla JavaScript, React, Vue, Angular,
8
+ and other browser frameworks. Styles live in a Shadow DOM so host styles do not
9
+ change the widget.
10
+
11
+ ## Set up your chatbot
12
+
13
+ 1. In SubZero, configure hosted Cocoa under **Agents → Cocoa**, or connect your
14
+ own agent. Hosted Cocoa is a $20/month add-on using your server-stored provider
15
+ key; your provider charges separately. Installing this UI does not start a
16
+ subscription or include a hosted agent.
17
+ 2. Under **Agents → Chatbots**, create a chatbot for your project and agent.
18
+ 3. Add your website's exact origin, including scheme and development port
19
+ (for example `https://app.example.com` or `http://localhost:3000`), and enable it.
20
+ 4. Copy the chatbot ID from Settings & installation into the example below.
21
+
22
+ Only the **public chatbot ID** belongs in this widget. Never embed a model API
23
+ key, SubZero agent token, staff login token, or private RDK credential.
24
+
25
+ ## Install with npm
26
+
27
+ ```sh
28
+ npm install @sub-zero/cocoa
29
+ ```
30
+
31
+ ```js
32
+ import { createCocoa } from '@sub-zero/cocoa';
33
+
34
+ const cocoa = createCocoa({
35
+ chatbotId: 'YOUR_CHATBOT_UUID',
36
+ });
37
+ ```
38
+
39
+ Call `createCocoa` after the document body exists. The bubble appears immediately;
40
+ no conversation is created until the visitor sends a message. The default API is
41
+ `https://api.sub-zero.dev`.
42
+
43
+ ### React / Next.js
44
+
45
+ Mount once in a client component or application layout. Importing the package is
46
+ safe on the server; mounting it requires a browser.
47
+
48
+ ```jsx
49
+ 'use client';
50
+ import { useEffect } from 'react';
51
+ import { createCocoa } from '@sub-zero/cocoa';
52
+
53
+ export function CocoaSupport() {
54
+ useEffect(() => {
55
+ const cocoa = createCocoa({ chatbotId: 'YOUR_CHATBOT_UUID' });
56
+ return () => cocoa.destroy();
57
+ }, []);
58
+ return null;
59
+ }
60
+ ```
61
+
62
+ For Vue, mount in `onMounted` and destroy in `onUnmounted`. For Angular, mount in
63
+ `ngAfterViewInit` in the browser and destroy in `ngOnDestroy`. In an SPA, mount in
64
+ the application shell rather than on each route. For the same API/chatbot pair,
65
+ repeated calls return the existing instance; the first caller's options apply.
66
+
67
+ ### Plain HTML
68
+
69
+ Copy `node_modules/@sub-zero/cocoa/dist/cocoa.js` to your public assets:
70
+
71
+ ```html
72
+ <script defer src="/cocoa.js" data-chatbot="YOUR_CHATBOT_UUID"></script>
73
+ ```
74
+
75
+ Optional attributes: `data-api`, `data-title`, `data-launcher-label`, and
76
+ `data-accent-color`. To initialize manually, load the script without
77
+ `data-chatbot` and call `window.Cocoa.createCocoa(options)`. Automatic initialization
78
+ exposes the handle as `window.Cocoa.widget`.
79
+
80
+ SubZero's dashboard-hosted `/subzero-chat.js` uses the same implementation and
81
+ continues to accept existing `data-api` and `data-chatbot` installation snippets.
82
+ For a reproducible release, self-host the file from a pinned npm version.
83
+
84
+ ## Options and controls
85
+
86
+ ```js
87
+ const cocoa = createCocoa({
88
+ chatbotId: 'YOUR_CHATBOT_UUID',
89
+ apiUrl: 'https://api.sub-zero.dev',
90
+ title: 'Acme support',
91
+ launcherLabel: 'Need help?',
92
+ welcomeMessage: 'Hi! How can we help?',
93
+ accentColor: '#385b50',
94
+ visitorLabel: 'Alex', // display name only; not a verified identity
95
+ persistSession: true,
96
+ zIndex: 2147483000,
97
+ pollInterval: 2500,
98
+ // nonce: yourCspNonce,
99
+ onError(error) { console.warn(error.message); },
100
+ });
101
+
102
+ cocoa.open();
103
+ cocoa.close();
104
+ cocoa.toggle();
105
+ cocoa.reset(); // forget this tab's conversation; does not delete server history
106
+ cocoa.destroy(); // remove UI, stop timers, abort requests; keep session for remount
107
+ ```
108
+
109
+ `reset()` aborts in-flight requests and clears local conversation state. A message
110
+ already accepted by the server cannot be recalled. Call it on application logout or
111
+ account switch to avoid resuming a previous visitor's conversation in that tab.
112
+ Use `persistSession: false` to keep credentials in memory only. With the default
113
+ setting, the visitor credential and any unacknowledged message retry are held in
114
+ sessionStorage. Blocked storage falls back to memory; private-browser reloads may
115
+ therefore start a new conversation. This package does not collect the current
116
+ page URL, app state, or account information.
117
+
118
+ The widget supports Escape to close, labeled form controls, polite announcement
119
+ of new messages, reduced motion, and narrow screens. It is a nonmodal dialog:
120
+ visitors can continue interacting with the host page. Replies are rendered as
121
+ plain text, never executable HTML or Markdown.
122
+
123
+ Polling runs only while the chat is open and the page is visible. Errors back off
124
+ up to 30 seconds; requests time out after 20 seconds. Message retries reuse an
125
+ idempotency key, including after reload when sessionStorage is available. Closing
126
+ or pausing a conversation disables sending; expired sessions offer a fresh start.
127
+ There is no substitute AI when the assigned agent is offline. Human replies
128
+ still arrive through the same conversation.
129
+
130
+ ## Security and hosting
131
+
132
+ Use HTTPS for the API. HTTP is supported only for loopback development hosts.
133
+ Visitor tokens are sent only to the configured API, with cookies omitted and
134
+ redirects rejected. An allowed website origin controls embedding; it does not
135
+ verify the visitor's identity. Do not provide account-specific private data
136
+ based only on a name typed into chat.
137
+
138
+ For Content Security Policy, allow the API under `connect-src`, the script source
139
+ under `script-src`, and supply a valid nonce through the `nonce` option (or script
140
+ nonce attribute) for the widget stylesheet. The widget uses DOM APIs and is
141
+ compatible with Trusted Types enforcement. No remote fonts, images,
142
+ iframes, analytics, model SDKs, or third-party styles are loaded.
143
+
144
+ Existing SubZero chat webhooks and human approval policies apply unchanged. RDK
145
+ knowledge and provider credentials are handled by the assigned agent and backend,
146
+ never by this package.
147
+
148
+ ## Maintainers
149
+
150
+ From the frontend repository:
151
+
152
+ ```sh
153
+ npm run build:cocoa
154
+ npm run test:cocoa
155
+ cd packages/cocoa
156
+ npm pack --dry-run
157
+ npm publish --access public
158
+ ```
159
+
160
+ The package build uses only Node built-ins. Browser tests use the frontend's
161
+ Playwright development dependency and installed Chromium. The published tarball
162
+ contains only `dist`, this README, and package metadata. Do not publish the private
163
+ Angular application root. Review the version and `npm pack --dry-run` output before
164
+ publishing. No open-source license is granted (`UNLICENSED`); the source repository
165
+ may be private.
package/dist/cocoa.js ADDED
@@ -0,0 +1,330 @@
1
+ /* @sub-zero/cocoa — generated by packages/cocoa/build.mjs */
2
+ (() => {
3
+ const instances = new Map();
4
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+
6
+ function createCocoa(options = {}) {
7
+ if (typeof document === 'undefined' || !document.body) {
8
+ throw new Error('Mount Cocoa in the browser after the document body exists.');
9
+ }
10
+ if (!uuid.test(options.chatbotId || '')) throw new Error('A valid chatbotId is required.');
11
+ const url = new URL(options.apiUrl || 'https://api.sub-zero.dev');
12
+ if (url.username || url.password || url.search || url.hash ||
13
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
14
+ throw new Error('apiUrl must use HTTPS (HTTP is allowed only on loopback).');
15
+ }
16
+ const api = url.href.replace(/\/+$/, '');
17
+ const key = `subzero-chat:${api}:${options.chatbotId}`;
18
+ if (instances.has(key)) return instances.get(key);
19
+ const accent = options.accentColor || '#385b50';
20
+ if (!/^#[0-9a-f]{6}$/i.test(accent)) throw new Error('accentColor must be a six-digit hex color.');
21
+ const zIndex = options.zIndex ?? 2147483000;
22
+ if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
23
+ const pollInterval = options.pollInterval ?? 2500;
24
+ if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
25
+ const host = document.createElement('div');
26
+ host.dataset.cocoaWidget = '';
27
+ const root = host.attachShadow({ mode: 'open' });
28
+ const style = document.createElement('style');
29
+ if (options.nonce) style.setAttribute('nonce', options.nonce);
30
+ style.textContent = `
31
+ :host{all:initial;position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:${zIndex};font:14px/1.5 system-ui,sans-serif;color:#f2f5f8;color-scheme:dark;text-align:left}
32
+ *,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
33
+ button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid #647078;border-radius:12px;padding:10px 14px;background:#26343a;color:#fff}button:hover{filter:brightness(1.15)}button:disabled{opacity:.55;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #99dbc4;outline-offset:2px}
34
+ .launcher{display:block;margin-left:auto;background:${accent};border-color:#ffffff55;box-shadow:0 6px 24px #0004;font-weight:600;min-height:48px}
35
+ .panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 100px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
36
+ header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #39454e}header strong{font-size:16px;overflow-wrap:anywhere}header small{display:block;color:#bac9d2}header button{min-width:44px;min-height:44px;font-size:22px;background:transparent}
37
+ .messages{flex:1;min-height:50px;overflow:auto;overscroll-behavior:contain;padding:16px;display:flex;flex-direction:column;gap:12px}.message{flex-shrink:0;align-self:flex-start;max-width:92%;background:#27333d;padding:10px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.customer{align-self:flex-end;background:${accent}}.message small{display:block;color:#fff;font-weight:600;font-size:11px;margin-bottom:4px}
38
+ .welcome{color:#c8d4dd;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.status{color:#c8d4dd;font-size:12px;margin:0;padding:8px 16px}.error{color:#ffc3c3}
39
+ form{padding:12px 16px;border-top:1px solid #39454e;display:grid;gap:8px}label{display:grid;gap:4px;font-size:12px}input,textarea{width:100%;border:1px solid #61717e;border-radius:8px;background:#1e2932;color:#fff;padding:8px;font-size:16px}textarea{height:64px;resize:none}.actions{display:flex;justify-content:flex-end;gap:8px}.new{margin-right:auto}
40
+ @media(prefers-reduced-motion:no-preference){.panel{animation:appear .15s ease-out}@keyframes appear{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}}
41
+ @media(max-height:540px){.panel{height:calc(100dvh - 96px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
42
+ `;
43
+ root.append(style);
44
+ // Use DOM APIs throughout, including on sites enforcing Trusted Types.
45
+ const element = (tag, attributes = {}, text) => {
46
+ const node = document.createElement(tag);
47
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
48
+ if (text !== undefined) node.textContent = text;
49
+ return node;
50
+ };
51
+ const panel = element('section', { class: 'panel', id: 'cocoa-panel', role: 'dialog', 'aria-label': options.title || 'Cocoa support', hidden: '' });
52
+ const header = element('header');
53
+ const heading = element('div');
54
+ heading.append(element('strong'), element('small', {}, 'Powered by SubZero'));
55
+ header.append(heading, element('button', { type: 'button', 'aria-label': 'Close support' }, '\u00d7'));
56
+ const composer = element('form');
57
+ const nameLabel = element('label', { class: 'name' }, 'Your name (optional)');
58
+ nameLabel.append(element('input', { maxlength: '120', autocomplete: 'given-name' }));
59
+ const messageLabel = element('label', {}, 'Your message');
60
+ messageLabel.append(element('textarea', { maxlength: '12000', required: '' }));
61
+ const actions = element('div', { class: 'actions' });
62
+ actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
63
+ composer.append(nameLabel, messageLabel, actions);
64
+ panel.append(header, element('div', { class: 'messages', role: 'log', 'aria-label': 'Conversation', 'aria-live': 'polite', 'aria-relevant': 'additions text' }), element('p', { class: 'status', role: 'status' }), composer);
65
+ panel.querySelector('strong').textContent = options.title || 'Cocoa support';
66
+ const launcher = document.createElement('button');
67
+ launcher.type = 'button';
68
+ launcher.className = 'launcher';
69
+ launcher.textContent = options.launcherLabel || 'Chat with Cocoa';
70
+ launcher.setAttribute('aria-expanded', 'false');
71
+ launcher.setAttribute('aria-controls', panel.id);
72
+ launcher.setAttribute('aria-haspopup', 'dialog');
73
+ root.append(panel, launcher);
74
+ document.body.append(host);
75
+ const messages = panel.querySelector('.messages');
76
+ const status = panel.querySelector('.status');
77
+ const form = panel.querySelector('form');
78
+ const input = panel.querySelector('textarea');
79
+ const name = panel.querySelector('input');
80
+ const send = panel.querySelector('.send');
81
+ const fresh = panel.querySelector('.new');
82
+ name.value = String(options.visitorLabel || '').slice(0, 120);
83
+ let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
84
+ let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
85
+ const rendered = new Set();
86
+ const requests = new Set();
87
+ const persist = () => {
88
+ if (options.persistSession === false) return;
89
+ try {
90
+ if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
91
+ else sessionStorage.removeItem(key);
92
+ } catch { /* Storage may be unavailable; an in-memory session still works. */ }
93
+ };
94
+ if (options.persistSession !== false) {
95
+ try {
96
+ const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
97
+ if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
98
+ session = { id: stored.id, token: stored.token };
99
+ if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
100
+ pending = stored.pending;
101
+ input.value = pending.body;
102
+ }
103
+ }
104
+ } catch { /* Ignore malformed storage. */ }
105
+ }
106
+ const welcome = () => {
107
+ const p = document.createElement('p');
108
+ p.className = 'welcome';
109
+ p.textContent = options.welcomeMessage || 'Hi! Tell us what you need help with. Cocoa and your support team can answer here.';
110
+ messages.replaceChildren(p);
111
+ };
112
+ welcome();
113
+ const controls = () => {
114
+ const focusWasInComposer = root.activeElement === input || root.activeElement === send;
115
+ input.disabled = sending || terminal || paused;
116
+ send.disabled = sending || terminal || paused;
117
+ name.disabled = sending;
118
+ fresh.hidden = !terminal;
119
+ panel.querySelector('.name').hidden = !!session;
120
+ send.textContent = sending ? 'Sending...' : 'Send';
121
+ if (focusWasInComposer && !sending && input.disabled && !panel.hidden) {
122
+ (terminal ? fresh : panel.querySelector('header button')).focus();
123
+ }
124
+ };
125
+ controls();
126
+ const report = (error) => {
127
+ status.classList.add('error');
128
+ status.textContent = error.message;
129
+ try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
130
+ };
131
+ async function call(path, body, credential) {
132
+ const controller = new AbortController();
133
+ requests.add(controller);
134
+ const timeout = setTimeout(() => controller.abort(), 20000);
135
+ try {
136
+ const response = await fetch(api + path, {
137
+ method: body === undefined ? 'GET' : 'POST',
138
+ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
139
+ headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
140
+ ...(credential ? { Authorization: `Bearer ${credential}` } : {}) },
141
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
142
+ signal: controller.signal,
143
+ });
144
+ if (!response.ok) {
145
+ const error = new Error(response.status === 401 ? 'This conversation has expired. Start a new conversation.' :
146
+ response.status === 429 ? 'Too many requests. Please wait a moment before trying again.' :
147
+ response.status === 404 ? 'Chat is unavailable. Please contact your support team.' :
148
+ response.status === 409 ? 'This conversation is closed or paused.' :
149
+ 'Unable to connect. Your unsent message is kept here; please try again.');
150
+ error.status = response.status;
151
+ throw error;
152
+ }
153
+ return await response.json();
154
+ } catch (error) {
155
+ if (error.status) throw error;
156
+ throw new Error('Unable to connect. Your unsent message is kept here; please try again.');
157
+ } finally { clearTimeout(timeout); requests.delete(controller); }
158
+ }
159
+ function expire(error) {
160
+ if (error.status === 401 || error.status === 404) {
161
+ terminal = true;
162
+ session = null;
163
+ pending = null;
164
+ persist();
165
+ controls();
166
+ }
167
+ }
168
+ function paint(data) {
169
+ if (!Array.isArray(data.messages)) throw new Error('Chat returned an invalid response. Please try again.');
170
+ if (data.version !== version) {
171
+ const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
172
+ if (data.messages.length) messages.querySelector('.welcome')?.remove();
173
+ for (const m of data.messages) {
174
+ if (rendered.has(m.id) || !['customer', 'agent', 'human'].includes(m.actor)) continue;
175
+ const item = document.createElement('div');
176
+ item.className = `message ${m.actor}`;
177
+ const label = document.createElement('small');
178
+ label.textContent = m.actor === 'customer' ? 'You' : m.actor === 'agent' ? (options.title || 'Cocoa') : 'Support team';
179
+ const text = document.createElement('span');
180
+ text.textContent = m.body;
181
+ item.append(label, text);
182
+ messages.append(item);
183
+ rendered.add(m.id);
184
+ }
185
+ if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
186
+ version = data.version;
187
+ }
188
+ terminal = data.state === 'closed';
189
+ paused = !data.enabled;
190
+ status.classList.remove('error');
191
+ status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
192
+ data.state === 'human' ? 'A person is handling your conversation.' :
193
+ data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
194
+ controls();
195
+ }
196
+ function schedule() {
197
+ clearTimeout(timer);
198
+ if (!destroyed && !panel.hidden && !document.hidden && session && !terminal) {
199
+ timer = setTimeout(refresh, Math.min(pollInterval * 2 ** failures, 30000));
200
+ }
201
+ }
202
+ async function refresh() {
203
+ if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
204
+ refreshing = true;
205
+ const current = generation;
206
+ try {
207
+ const data = await call(`/chat/sessions/${session.id}`, undefined, session.token);
208
+ if (destroyed || current !== generation) return;
209
+ paint(data);
210
+ failures = 0;
211
+ } catch (error) {
212
+ if (!destroyed && current === generation) { failures = Math.min(failures + 1, 4); expire(error); report(error); }
213
+ } finally {
214
+ if (current === generation) { refreshing = false; schedule(); }
215
+ }
216
+ }
217
+ function close() {
218
+ if (destroyed) return;
219
+ panel.hidden = true;
220
+ launcher.setAttribute('aria-expanded', 'false');
221
+ clearTimeout(timer);
222
+ launcher.focus();
223
+ }
224
+ function open() {
225
+ if (destroyed) return;
226
+ panel.hidden = false;
227
+ launcher.setAttribute('aria-expanded', 'true');
228
+ (terminal ? fresh : input).focus();
229
+ void refresh();
230
+ }
231
+ function reset() {
232
+ if (destroyed) return;
233
+ generation++;
234
+ sending = refreshing = false;
235
+ for (const request of requests) request.abort();
236
+ clearTimeout(timer);
237
+ session = pending = null;
238
+ terminal = paused = false;
239
+ version = -1;
240
+ failures = 0;
241
+ rendered.clear();
242
+ persist();
243
+ welcome();
244
+ input.value = '';
245
+ status.textContent = '';
246
+ status.classList.remove('error');
247
+ controls();
248
+ if (!panel.hidden) input.focus();
249
+ }
250
+ const visibility = () => {
251
+ clearTimeout(timer);
252
+ if (!document.hidden) void refresh();
253
+ };
254
+ document.addEventListener('visibilitychange', visibility);
255
+ const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
256
+ if (destroyed) return;
257
+ destroyed = true;
258
+ generation++;
259
+ clearTimeout(timer);
260
+ for (const request of requests) request.abort();
261
+ document.removeEventListener('visibilitychange', visibility);
262
+ host.remove();
263
+ instances.delete(key);
264
+ } };
265
+ launcher.onclick = widget.toggle;
266
+ panel.querySelector('header button').onclick = close;
267
+ fresh.onclick = reset;
268
+ root.addEventListener('keydown', (event) => {
269
+ if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(); }
270
+ });
271
+ form.onsubmit = async (event) => {
272
+ event.preventDefault();
273
+ const body = input.value.trim();
274
+ if (!body || sending || terminal || paused || destroyed) return;
275
+ sending = true;
276
+ controls();
277
+ clearTimeout(timer);
278
+ const current = generation;
279
+ try {
280
+ if (!session) {
281
+ const result = await call(`/chat/${options.chatbotId}/sessions`, { label: name.value.trim() || 'Visitor' });
282
+ if (destroyed || current !== generation) return;
283
+ if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
284
+ session = { id: result.id, token: result.token };
285
+ persist();
286
+ }
287
+ if (!pending || pending.body !== body) pending = { body, idempotencyKey: crypto.randomUUID() };
288
+ persist();
289
+ await call(`/chat/sessions/${session.id}/messages`, pending, session.token);
290
+ if (destroyed || current !== generation) return;
291
+ pending = null;
292
+ persist();
293
+ input.value = '';
294
+ failures = 0;
295
+ status.classList.remove('error');
296
+ status.textContent = 'Message sent.';
297
+ } catch (error) {
298
+ if (!destroyed && current === generation) { expire(error); report(error); }
299
+ } finally {
300
+ if (!destroyed && current === generation) {
301
+ sending = false;
302
+ controls();
303
+ // Keep send failures visible until the visitor retries; polling resumes later.
304
+ if (!pending && !terminal) await refresh();
305
+ else schedule();
306
+ if (!panel.hidden && !input.disabled) input.focus();
307
+ }
308
+ }
309
+ };
310
+ instances.set(key, widget);
311
+ return widget;
312
+ }
313
+
314
+ const script = document.currentScript;
315
+ window.Cocoa = { ...(window.Cocoa || {}), createCocoa };
316
+ if (script?.dataset.chatbot) {
317
+ const options = {
318
+ chatbotId: script.dataset.chatbot,
319
+ apiUrl: script.dataset.api,
320
+ title: script.dataset.title,
321
+ launcherLabel: script.dataset.launcherLabel,
322
+ accentColor: script.dataset.accentColor,
323
+ nonce: script.nonce,
324
+ };
325
+ const mount = () => { window.Cocoa.widget = createCocoa(options); };
326
+ if (document.body) mount();
327
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
328
+ }
329
+
330
+ })();
package/dist/index.cjs ADDED
@@ -0,0 +1,312 @@
1
+ const instances = new Map();
2
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+
4
+ function createCocoa(options = {}) {
5
+ if (typeof document === 'undefined' || !document.body) {
6
+ throw new Error('Mount Cocoa in the browser after the document body exists.');
7
+ }
8
+ if (!uuid.test(options.chatbotId || '')) throw new Error('A valid chatbotId is required.');
9
+ const url = new URL(options.apiUrl || 'https://api.sub-zero.dev');
10
+ if (url.username || url.password || url.search || url.hash ||
11
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
12
+ throw new Error('apiUrl must use HTTPS (HTTP is allowed only on loopback).');
13
+ }
14
+ const api = url.href.replace(/\/+$/, '');
15
+ const key = `subzero-chat:${api}:${options.chatbotId}`;
16
+ if (instances.has(key)) return instances.get(key);
17
+ const accent = options.accentColor || '#385b50';
18
+ if (!/^#[0-9a-f]{6}$/i.test(accent)) throw new Error('accentColor must be a six-digit hex color.');
19
+ const zIndex = options.zIndex ?? 2147483000;
20
+ if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
21
+ const pollInterval = options.pollInterval ?? 2500;
22
+ if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
23
+ const host = document.createElement('div');
24
+ host.dataset.cocoaWidget = '';
25
+ const root = host.attachShadow({ mode: 'open' });
26
+ const style = document.createElement('style');
27
+ if (options.nonce) style.setAttribute('nonce', options.nonce);
28
+ style.textContent = `
29
+ :host{all:initial;position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:${zIndex};font:14px/1.5 system-ui,sans-serif;color:#f2f5f8;color-scheme:dark;text-align:left}
30
+ *,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
31
+ button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid #647078;border-radius:12px;padding:10px 14px;background:#26343a;color:#fff}button:hover{filter:brightness(1.15)}button:disabled{opacity:.55;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #99dbc4;outline-offset:2px}
32
+ .launcher{display:block;margin-left:auto;background:${accent};border-color:#ffffff55;box-shadow:0 6px 24px #0004;font-weight:600;min-height:48px}
33
+ .panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 100px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
34
+ header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #39454e}header strong{font-size:16px;overflow-wrap:anywhere}header small{display:block;color:#bac9d2}header button{min-width:44px;min-height:44px;font-size:22px;background:transparent}
35
+ .messages{flex:1;min-height:50px;overflow:auto;overscroll-behavior:contain;padding:16px;display:flex;flex-direction:column;gap:12px}.message{flex-shrink:0;align-self:flex-start;max-width:92%;background:#27333d;padding:10px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.customer{align-self:flex-end;background:${accent}}.message small{display:block;color:#fff;font-weight:600;font-size:11px;margin-bottom:4px}
36
+ .welcome{color:#c8d4dd;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.status{color:#c8d4dd;font-size:12px;margin:0;padding:8px 16px}.error{color:#ffc3c3}
37
+ form{padding:12px 16px;border-top:1px solid #39454e;display:grid;gap:8px}label{display:grid;gap:4px;font-size:12px}input,textarea{width:100%;border:1px solid #61717e;border-radius:8px;background:#1e2932;color:#fff;padding:8px;font-size:16px}textarea{height:64px;resize:none}.actions{display:flex;justify-content:flex-end;gap:8px}.new{margin-right:auto}
38
+ @media(prefers-reduced-motion:no-preference){.panel{animation:appear .15s ease-out}@keyframes appear{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}}
39
+ @media(max-height:540px){.panel{height:calc(100dvh - 96px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
40
+ `;
41
+ root.append(style);
42
+ // Use DOM APIs throughout, including on sites enforcing Trusted Types.
43
+ const element = (tag, attributes = {}, text) => {
44
+ const node = document.createElement(tag);
45
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
46
+ if (text !== undefined) node.textContent = text;
47
+ return node;
48
+ };
49
+ const panel = element('section', { class: 'panel', id: 'cocoa-panel', role: 'dialog', 'aria-label': options.title || 'Cocoa support', hidden: '' });
50
+ const header = element('header');
51
+ const heading = element('div');
52
+ heading.append(element('strong'), element('small', {}, 'Powered by SubZero'));
53
+ header.append(heading, element('button', { type: 'button', 'aria-label': 'Close support' }, '\u00d7'));
54
+ const composer = element('form');
55
+ const nameLabel = element('label', { class: 'name' }, 'Your name (optional)');
56
+ nameLabel.append(element('input', { maxlength: '120', autocomplete: 'given-name' }));
57
+ const messageLabel = element('label', {}, 'Your message');
58
+ messageLabel.append(element('textarea', { maxlength: '12000', required: '' }));
59
+ const actions = element('div', { class: 'actions' });
60
+ actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
61
+ composer.append(nameLabel, messageLabel, actions);
62
+ panel.append(header, element('div', { class: 'messages', role: 'log', 'aria-label': 'Conversation', 'aria-live': 'polite', 'aria-relevant': 'additions text' }), element('p', { class: 'status', role: 'status' }), composer);
63
+ panel.querySelector('strong').textContent = options.title || 'Cocoa support';
64
+ const launcher = document.createElement('button');
65
+ launcher.type = 'button';
66
+ launcher.className = 'launcher';
67
+ launcher.textContent = options.launcherLabel || 'Chat with Cocoa';
68
+ launcher.setAttribute('aria-expanded', 'false');
69
+ launcher.setAttribute('aria-controls', panel.id);
70
+ launcher.setAttribute('aria-haspopup', 'dialog');
71
+ root.append(panel, launcher);
72
+ document.body.append(host);
73
+ const messages = panel.querySelector('.messages');
74
+ const status = panel.querySelector('.status');
75
+ const form = panel.querySelector('form');
76
+ const input = panel.querySelector('textarea');
77
+ const name = panel.querySelector('input');
78
+ const send = panel.querySelector('.send');
79
+ const fresh = panel.querySelector('.new');
80
+ name.value = String(options.visitorLabel || '').slice(0, 120);
81
+ let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
82
+ let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
83
+ const rendered = new Set();
84
+ const requests = new Set();
85
+ const persist = () => {
86
+ if (options.persistSession === false) return;
87
+ try {
88
+ if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
89
+ else sessionStorage.removeItem(key);
90
+ } catch { /* Storage may be unavailable; an in-memory session still works. */ }
91
+ };
92
+ if (options.persistSession !== false) {
93
+ try {
94
+ const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
95
+ if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
96
+ session = { id: stored.id, token: stored.token };
97
+ if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
98
+ pending = stored.pending;
99
+ input.value = pending.body;
100
+ }
101
+ }
102
+ } catch { /* Ignore malformed storage. */ }
103
+ }
104
+ const welcome = () => {
105
+ const p = document.createElement('p');
106
+ p.className = 'welcome';
107
+ p.textContent = options.welcomeMessage || 'Hi! Tell us what you need help with. Cocoa and your support team can answer here.';
108
+ messages.replaceChildren(p);
109
+ };
110
+ welcome();
111
+ const controls = () => {
112
+ const focusWasInComposer = root.activeElement === input || root.activeElement === send;
113
+ input.disabled = sending || terminal || paused;
114
+ send.disabled = sending || terminal || paused;
115
+ name.disabled = sending;
116
+ fresh.hidden = !terminal;
117
+ panel.querySelector('.name').hidden = !!session;
118
+ send.textContent = sending ? 'Sending...' : 'Send';
119
+ if (focusWasInComposer && !sending && input.disabled && !panel.hidden) {
120
+ (terminal ? fresh : panel.querySelector('header button')).focus();
121
+ }
122
+ };
123
+ controls();
124
+ const report = (error) => {
125
+ status.classList.add('error');
126
+ status.textContent = error.message;
127
+ try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
128
+ };
129
+ async function call(path, body, credential) {
130
+ const controller = new AbortController();
131
+ requests.add(controller);
132
+ const timeout = setTimeout(() => controller.abort(), 20000);
133
+ try {
134
+ const response = await fetch(api + path, {
135
+ method: body === undefined ? 'GET' : 'POST',
136
+ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
137
+ headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
138
+ ...(credential ? { Authorization: `Bearer ${credential}` } : {}) },
139
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
140
+ signal: controller.signal,
141
+ });
142
+ if (!response.ok) {
143
+ const error = new Error(response.status === 401 ? 'This conversation has expired. Start a new conversation.' :
144
+ response.status === 429 ? 'Too many requests. Please wait a moment before trying again.' :
145
+ response.status === 404 ? 'Chat is unavailable. Please contact your support team.' :
146
+ response.status === 409 ? 'This conversation is closed or paused.' :
147
+ 'Unable to connect. Your unsent message is kept here; please try again.');
148
+ error.status = response.status;
149
+ throw error;
150
+ }
151
+ return await response.json();
152
+ } catch (error) {
153
+ if (error.status) throw error;
154
+ throw new Error('Unable to connect. Your unsent message is kept here; please try again.');
155
+ } finally { clearTimeout(timeout); requests.delete(controller); }
156
+ }
157
+ function expire(error) {
158
+ if (error.status === 401 || error.status === 404) {
159
+ terminal = true;
160
+ session = null;
161
+ pending = null;
162
+ persist();
163
+ controls();
164
+ }
165
+ }
166
+ function paint(data) {
167
+ if (!Array.isArray(data.messages)) throw new Error('Chat returned an invalid response. Please try again.');
168
+ if (data.version !== version) {
169
+ const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
170
+ if (data.messages.length) messages.querySelector('.welcome')?.remove();
171
+ for (const m of data.messages) {
172
+ if (rendered.has(m.id) || !['customer', 'agent', 'human'].includes(m.actor)) continue;
173
+ const item = document.createElement('div');
174
+ item.className = `message ${m.actor}`;
175
+ const label = document.createElement('small');
176
+ label.textContent = m.actor === 'customer' ? 'You' : m.actor === 'agent' ? (options.title || 'Cocoa') : 'Support team';
177
+ const text = document.createElement('span');
178
+ text.textContent = m.body;
179
+ item.append(label, text);
180
+ messages.append(item);
181
+ rendered.add(m.id);
182
+ }
183
+ if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
184
+ version = data.version;
185
+ }
186
+ terminal = data.state === 'closed';
187
+ paused = !data.enabled;
188
+ status.classList.remove('error');
189
+ status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
190
+ data.state === 'human' ? 'A person is handling your conversation.' :
191
+ data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
192
+ controls();
193
+ }
194
+ function schedule() {
195
+ clearTimeout(timer);
196
+ if (!destroyed && !panel.hidden && !document.hidden && session && !terminal) {
197
+ timer = setTimeout(refresh, Math.min(pollInterval * 2 ** failures, 30000));
198
+ }
199
+ }
200
+ async function refresh() {
201
+ if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
202
+ refreshing = true;
203
+ const current = generation;
204
+ try {
205
+ const data = await call(`/chat/sessions/${session.id}`, undefined, session.token);
206
+ if (destroyed || current !== generation) return;
207
+ paint(data);
208
+ failures = 0;
209
+ } catch (error) {
210
+ if (!destroyed && current === generation) { failures = Math.min(failures + 1, 4); expire(error); report(error); }
211
+ } finally {
212
+ if (current === generation) { refreshing = false; schedule(); }
213
+ }
214
+ }
215
+ function close() {
216
+ if (destroyed) return;
217
+ panel.hidden = true;
218
+ launcher.setAttribute('aria-expanded', 'false');
219
+ clearTimeout(timer);
220
+ launcher.focus();
221
+ }
222
+ function open() {
223
+ if (destroyed) return;
224
+ panel.hidden = false;
225
+ launcher.setAttribute('aria-expanded', 'true');
226
+ (terminal ? fresh : input).focus();
227
+ void refresh();
228
+ }
229
+ function reset() {
230
+ if (destroyed) return;
231
+ generation++;
232
+ sending = refreshing = false;
233
+ for (const request of requests) request.abort();
234
+ clearTimeout(timer);
235
+ session = pending = null;
236
+ terminal = paused = false;
237
+ version = -1;
238
+ failures = 0;
239
+ rendered.clear();
240
+ persist();
241
+ welcome();
242
+ input.value = '';
243
+ status.textContent = '';
244
+ status.classList.remove('error');
245
+ controls();
246
+ if (!panel.hidden) input.focus();
247
+ }
248
+ const visibility = () => {
249
+ clearTimeout(timer);
250
+ if (!document.hidden) void refresh();
251
+ };
252
+ document.addEventListener('visibilitychange', visibility);
253
+ const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
254
+ if (destroyed) return;
255
+ destroyed = true;
256
+ generation++;
257
+ clearTimeout(timer);
258
+ for (const request of requests) request.abort();
259
+ document.removeEventListener('visibilitychange', visibility);
260
+ host.remove();
261
+ instances.delete(key);
262
+ } };
263
+ launcher.onclick = widget.toggle;
264
+ panel.querySelector('header button').onclick = close;
265
+ fresh.onclick = reset;
266
+ root.addEventListener('keydown', (event) => {
267
+ if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(); }
268
+ });
269
+ form.onsubmit = async (event) => {
270
+ event.preventDefault();
271
+ const body = input.value.trim();
272
+ if (!body || sending || terminal || paused || destroyed) return;
273
+ sending = true;
274
+ controls();
275
+ clearTimeout(timer);
276
+ const current = generation;
277
+ try {
278
+ if (!session) {
279
+ const result = await call(`/chat/${options.chatbotId}/sessions`, { label: name.value.trim() || 'Visitor' });
280
+ if (destroyed || current !== generation) return;
281
+ if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
282
+ session = { id: result.id, token: result.token };
283
+ persist();
284
+ }
285
+ if (!pending || pending.body !== body) pending = { body, idempotencyKey: crypto.randomUUID() };
286
+ persist();
287
+ await call(`/chat/sessions/${session.id}/messages`, pending, session.token);
288
+ if (destroyed || current !== generation) return;
289
+ pending = null;
290
+ persist();
291
+ input.value = '';
292
+ failures = 0;
293
+ status.classList.remove('error');
294
+ status.textContent = 'Message sent.';
295
+ } catch (error) {
296
+ if (!destroyed && current === generation) { expire(error); report(error); }
297
+ } finally {
298
+ if (!destroyed && current === generation) {
299
+ sending = false;
300
+ controls();
301
+ // Keep send failures visible until the visitor retries; polling resumes later.
302
+ if (!pending && !terminal) await refresh();
303
+ else schedule();
304
+ if (!panel.hidden && !input.disabled) input.focus();
305
+ }
306
+ }
307
+ };
308
+ instances.set(key, widget);
309
+ return widget;
310
+ }
311
+
312
+ module.exports = { createCocoa };
@@ -0,0 +1,33 @@
1
+ export interface CocoaOptions {
2
+ /** Public deployment UUID from SubZero → Agents → Chatbots. */
3
+ chatbotId: string;
4
+ /** Defaults to https://api.sub-zero.dev. HTTPS required except on loopback. */
5
+ apiUrl?: string;
6
+ title?: string;
7
+ launcherLabel?: string;
8
+ welcomeMessage?: string;
9
+ /** Unverified display name, never an authentication credential. */
10
+ visitorLabel?: string;
11
+ /** Six-digit hex color, e.g. #385b50. */
12
+ accentColor?: string;
13
+ zIndex?: number;
14
+ /** Defaults to true. Stores the visitor credential in this tab's sessionStorage. */
15
+ persistSession?: boolean;
16
+ /** CSP nonce for the widget's isolated stylesheet. */
17
+ nonce?: string;
18
+ /** Minimum 2500ms; defaults to 2500. Only polls while visible. */
19
+ pollInterval?: number;
20
+ /** Receives sanitized errors; no visitor credentials or transcript. */
21
+ onError?: (error: Error) => void;
22
+ }
23
+ export interface CocoaWidget {
24
+ open(): void;
25
+ close(): void;
26
+ toggle(): void;
27
+ /** Forget this tab's session; does not delete its server transcript. */
28
+ reset(): void;
29
+ /** Remove UI, timers, listeners and abort in-flight requests. */
30
+ destroy(): void;
31
+ }
32
+ /** Call after the document body exists. Importing is safe during SSR. */
33
+ export function createCocoa(options: CocoaOptions): CocoaWidget;
@@ -0,0 +1,33 @@
1
+ export interface CocoaOptions {
2
+ /** Public deployment UUID from SubZero → Agents → Chatbots. */
3
+ chatbotId: string;
4
+ /** Defaults to https://api.sub-zero.dev. HTTPS required except on loopback. */
5
+ apiUrl?: string;
6
+ title?: string;
7
+ launcherLabel?: string;
8
+ welcomeMessage?: string;
9
+ /** Unverified display name, never an authentication credential. */
10
+ visitorLabel?: string;
11
+ /** Six-digit hex color, e.g. #385b50. */
12
+ accentColor?: string;
13
+ zIndex?: number;
14
+ /** Defaults to true. Stores the visitor credential in this tab's sessionStorage. */
15
+ persistSession?: boolean;
16
+ /** CSP nonce for the widget's isolated stylesheet. */
17
+ nonce?: string;
18
+ /** Minimum 2500ms; defaults to 2500. Only polls while visible. */
19
+ pollInterval?: number;
20
+ /** Receives sanitized errors; no visitor credentials or transcript. */
21
+ onError?: (error: Error) => void;
22
+ }
23
+ export interface CocoaWidget {
24
+ open(): void;
25
+ close(): void;
26
+ toggle(): void;
27
+ /** Forget this tab's session; does not delete its server transcript. */
28
+ reset(): void;
29
+ /** Remove UI, timers, listeners and abort in-flight requests. */
30
+ destroy(): void;
31
+ }
32
+ /** Call after the document body exists. Importing is safe during SSR. */
33
+ export function createCocoa(options: CocoaOptions): CocoaWidget;
package/dist/index.js ADDED
@@ -0,0 +1,310 @@
1
+ const instances = new Map();
2
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+
4
+ export function createCocoa(options = {}) {
5
+ if (typeof document === 'undefined' || !document.body) {
6
+ throw new Error('Mount Cocoa in the browser after the document body exists.');
7
+ }
8
+ if (!uuid.test(options.chatbotId || '')) throw new Error('A valid chatbotId is required.');
9
+ const url = new URL(options.apiUrl || 'https://api.sub-zero.dev');
10
+ if (url.username || url.password || url.search || url.hash ||
11
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
12
+ throw new Error('apiUrl must use HTTPS (HTTP is allowed only on loopback).');
13
+ }
14
+ const api = url.href.replace(/\/+$/, '');
15
+ const key = `subzero-chat:${api}:${options.chatbotId}`;
16
+ if (instances.has(key)) return instances.get(key);
17
+ const accent = options.accentColor || '#385b50';
18
+ if (!/^#[0-9a-f]{6}$/i.test(accent)) throw new Error('accentColor must be a six-digit hex color.');
19
+ const zIndex = options.zIndex ?? 2147483000;
20
+ if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
21
+ const pollInterval = options.pollInterval ?? 2500;
22
+ if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
23
+ const host = document.createElement('div');
24
+ host.dataset.cocoaWidget = '';
25
+ const root = host.attachShadow({ mode: 'open' });
26
+ const style = document.createElement('style');
27
+ if (options.nonce) style.setAttribute('nonce', options.nonce);
28
+ style.textContent = `
29
+ :host{all:initial;position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:${zIndex};font:14px/1.5 system-ui,sans-serif;color:#f2f5f8;color-scheme:dark;text-align:left}
30
+ *,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
31
+ button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid #647078;border-radius:12px;padding:10px 14px;background:#26343a;color:#fff}button:hover{filter:brightness(1.15)}button:disabled{opacity:.55;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #99dbc4;outline-offset:2px}
32
+ .launcher{display:block;margin-left:auto;background:${accent};border-color:#ffffff55;box-shadow:0 6px 24px #0004;font-weight:600;min-height:48px}
33
+ .panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 100px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
34
+ header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid #39454e}header strong{font-size:16px;overflow-wrap:anywhere}header small{display:block;color:#bac9d2}header button{min-width:44px;min-height:44px;font-size:22px;background:transparent}
35
+ .messages{flex:1;min-height:50px;overflow:auto;overscroll-behavior:contain;padding:16px;display:flex;flex-direction:column;gap:12px}.message{flex-shrink:0;align-self:flex-start;max-width:92%;background:#27333d;padding:10px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}.customer{align-self:flex-end;background:${accent}}.message small{display:block;color:#fff;font-weight:600;font-size:11px;margin-bottom:4px}
36
+ .welcome{color:#c8d4dd;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.status{color:#c8d4dd;font-size:12px;margin:0;padding:8px 16px}.error{color:#ffc3c3}
37
+ form{padding:12px 16px;border-top:1px solid #39454e;display:grid;gap:8px}label{display:grid;gap:4px;font-size:12px}input,textarea{width:100%;border:1px solid #61717e;border-radius:8px;background:#1e2932;color:#fff;padding:8px;font-size:16px}textarea{height:64px;resize:none}.actions{display:flex;justify-content:flex-end;gap:8px}.new{margin-right:auto}
38
+ @media(prefers-reduced-motion:no-preference){.panel{animation:appear .15s ease-out}@keyframes appear{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}}
39
+ @media(max-height:540px){.panel{height:calc(100dvh - 96px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
40
+ `;
41
+ root.append(style);
42
+ // Use DOM APIs throughout, including on sites enforcing Trusted Types.
43
+ const element = (tag, attributes = {}, text) => {
44
+ const node = document.createElement(tag);
45
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
46
+ if (text !== undefined) node.textContent = text;
47
+ return node;
48
+ };
49
+ const panel = element('section', { class: 'panel', id: 'cocoa-panel', role: 'dialog', 'aria-label': options.title || 'Cocoa support', hidden: '' });
50
+ const header = element('header');
51
+ const heading = element('div');
52
+ heading.append(element('strong'), element('small', {}, 'Powered by SubZero'));
53
+ header.append(heading, element('button', { type: 'button', 'aria-label': 'Close support' }, '\u00d7'));
54
+ const composer = element('form');
55
+ const nameLabel = element('label', { class: 'name' }, 'Your name (optional)');
56
+ nameLabel.append(element('input', { maxlength: '120', autocomplete: 'given-name' }));
57
+ const messageLabel = element('label', {}, 'Your message');
58
+ messageLabel.append(element('textarea', { maxlength: '12000', required: '' }));
59
+ const actions = element('div', { class: 'actions' });
60
+ actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
61
+ composer.append(nameLabel, messageLabel, actions);
62
+ panel.append(header, element('div', { class: 'messages', role: 'log', 'aria-label': 'Conversation', 'aria-live': 'polite', 'aria-relevant': 'additions text' }), element('p', { class: 'status', role: 'status' }), composer);
63
+ panel.querySelector('strong').textContent = options.title || 'Cocoa support';
64
+ const launcher = document.createElement('button');
65
+ launcher.type = 'button';
66
+ launcher.className = 'launcher';
67
+ launcher.textContent = options.launcherLabel || 'Chat with Cocoa';
68
+ launcher.setAttribute('aria-expanded', 'false');
69
+ launcher.setAttribute('aria-controls', panel.id);
70
+ launcher.setAttribute('aria-haspopup', 'dialog');
71
+ root.append(panel, launcher);
72
+ document.body.append(host);
73
+ const messages = panel.querySelector('.messages');
74
+ const status = panel.querySelector('.status');
75
+ const form = panel.querySelector('form');
76
+ const input = panel.querySelector('textarea');
77
+ const name = panel.querySelector('input');
78
+ const send = panel.querySelector('.send');
79
+ const fresh = panel.querySelector('.new');
80
+ name.value = String(options.visitorLabel || '').slice(0, 120);
81
+ let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
82
+ let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
83
+ const rendered = new Set();
84
+ const requests = new Set();
85
+ const persist = () => {
86
+ if (options.persistSession === false) return;
87
+ try {
88
+ if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
89
+ else sessionStorage.removeItem(key);
90
+ } catch { /* Storage may be unavailable; an in-memory session still works. */ }
91
+ };
92
+ if (options.persistSession !== false) {
93
+ try {
94
+ const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
95
+ if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
96
+ session = { id: stored.id, token: stored.token };
97
+ if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
98
+ pending = stored.pending;
99
+ input.value = pending.body;
100
+ }
101
+ }
102
+ } catch { /* Ignore malformed storage. */ }
103
+ }
104
+ const welcome = () => {
105
+ const p = document.createElement('p');
106
+ p.className = 'welcome';
107
+ p.textContent = options.welcomeMessage || 'Hi! Tell us what you need help with. Cocoa and your support team can answer here.';
108
+ messages.replaceChildren(p);
109
+ };
110
+ welcome();
111
+ const controls = () => {
112
+ const focusWasInComposer = root.activeElement === input || root.activeElement === send;
113
+ input.disabled = sending || terminal || paused;
114
+ send.disabled = sending || terminal || paused;
115
+ name.disabled = sending;
116
+ fresh.hidden = !terminal;
117
+ panel.querySelector('.name').hidden = !!session;
118
+ send.textContent = sending ? 'Sending...' : 'Send';
119
+ if (focusWasInComposer && !sending && input.disabled && !panel.hidden) {
120
+ (terminal ? fresh : panel.querySelector('header button')).focus();
121
+ }
122
+ };
123
+ controls();
124
+ const report = (error) => {
125
+ status.classList.add('error');
126
+ status.textContent = error.message;
127
+ try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
128
+ };
129
+ async function call(path, body, credential) {
130
+ const controller = new AbortController();
131
+ requests.add(controller);
132
+ const timeout = setTimeout(() => controller.abort(), 20000);
133
+ try {
134
+ const response = await fetch(api + path, {
135
+ method: body === undefined ? 'GET' : 'POST',
136
+ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
137
+ headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
138
+ ...(credential ? { Authorization: `Bearer ${credential}` } : {}) },
139
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
140
+ signal: controller.signal,
141
+ });
142
+ if (!response.ok) {
143
+ const error = new Error(response.status === 401 ? 'This conversation has expired. Start a new conversation.' :
144
+ response.status === 429 ? 'Too many requests. Please wait a moment before trying again.' :
145
+ response.status === 404 ? 'Chat is unavailable. Please contact your support team.' :
146
+ response.status === 409 ? 'This conversation is closed or paused.' :
147
+ 'Unable to connect. Your unsent message is kept here; please try again.');
148
+ error.status = response.status;
149
+ throw error;
150
+ }
151
+ return await response.json();
152
+ } catch (error) {
153
+ if (error.status) throw error;
154
+ throw new Error('Unable to connect. Your unsent message is kept here; please try again.');
155
+ } finally { clearTimeout(timeout); requests.delete(controller); }
156
+ }
157
+ function expire(error) {
158
+ if (error.status === 401 || error.status === 404) {
159
+ terminal = true;
160
+ session = null;
161
+ pending = null;
162
+ persist();
163
+ controls();
164
+ }
165
+ }
166
+ function paint(data) {
167
+ if (!Array.isArray(data.messages)) throw new Error('Chat returned an invalid response. Please try again.');
168
+ if (data.version !== version) {
169
+ const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
170
+ if (data.messages.length) messages.querySelector('.welcome')?.remove();
171
+ for (const m of data.messages) {
172
+ if (rendered.has(m.id) || !['customer', 'agent', 'human'].includes(m.actor)) continue;
173
+ const item = document.createElement('div');
174
+ item.className = `message ${m.actor}`;
175
+ const label = document.createElement('small');
176
+ label.textContent = m.actor === 'customer' ? 'You' : m.actor === 'agent' ? (options.title || 'Cocoa') : 'Support team';
177
+ const text = document.createElement('span');
178
+ text.textContent = m.body;
179
+ item.append(label, text);
180
+ messages.append(item);
181
+ rendered.add(m.id);
182
+ }
183
+ if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
184
+ version = data.version;
185
+ }
186
+ terminal = data.state === 'closed';
187
+ paused = !data.enabled;
188
+ status.classList.remove('error');
189
+ status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
190
+ data.state === 'human' ? 'A person is handling your conversation.' :
191
+ data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
192
+ controls();
193
+ }
194
+ function schedule() {
195
+ clearTimeout(timer);
196
+ if (!destroyed && !panel.hidden && !document.hidden && session && !terminal) {
197
+ timer = setTimeout(refresh, Math.min(pollInterval * 2 ** failures, 30000));
198
+ }
199
+ }
200
+ async function refresh() {
201
+ if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
202
+ refreshing = true;
203
+ const current = generation;
204
+ try {
205
+ const data = await call(`/chat/sessions/${session.id}`, undefined, session.token);
206
+ if (destroyed || current !== generation) return;
207
+ paint(data);
208
+ failures = 0;
209
+ } catch (error) {
210
+ if (!destroyed && current === generation) { failures = Math.min(failures + 1, 4); expire(error); report(error); }
211
+ } finally {
212
+ if (current === generation) { refreshing = false; schedule(); }
213
+ }
214
+ }
215
+ function close() {
216
+ if (destroyed) return;
217
+ panel.hidden = true;
218
+ launcher.setAttribute('aria-expanded', 'false');
219
+ clearTimeout(timer);
220
+ launcher.focus();
221
+ }
222
+ function open() {
223
+ if (destroyed) return;
224
+ panel.hidden = false;
225
+ launcher.setAttribute('aria-expanded', 'true');
226
+ (terminal ? fresh : input).focus();
227
+ void refresh();
228
+ }
229
+ function reset() {
230
+ if (destroyed) return;
231
+ generation++;
232
+ sending = refreshing = false;
233
+ for (const request of requests) request.abort();
234
+ clearTimeout(timer);
235
+ session = pending = null;
236
+ terminal = paused = false;
237
+ version = -1;
238
+ failures = 0;
239
+ rendered.clear();
240
+ persist();
241
+ welcome();
242
+ input.value = '';
243
+ status.textContent = '';
244
+ status.classList.remove('error');
245
+ controls();
246
+ if (!panel.hidden) input.focus();
247
+ }
248
+ const visibility = () => {
249
+ clearTimeout(timer);
250
+ if (!document.hidden) void refresh();
251
+ };
252
+ document.addEventListener('visibilitychange', visibility);
253
+ const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
254
+ if (destroyed) return;
255
+ destroyed = true;
256
+ generation++;
257
+ clearTimeout(timer);
258
+ for (const request of requests) request.abort();
259
+ document.removeEventListener('visibilitychange', visibility);
260
+ host.remove();
261
+ instances.delete(key);
262
+ } };
263
+ launcher.onclick = widget.toggle;
264
+ panel.querySelector('header button').onclick = close;
265
+ fresh.onclick = reset;
266
+ root.addEventListener('keydown', (event) => {
267
+ if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(); }
268
+ });
269
+ form.onsubmit = async (event) => {
270
+ event.preventDefault();
271
+ const body = input.value.trim();
272
+ if (!body || sending || terminal || paused || destroyed) return;
273
+ sending = true;
274
+ controls();
275
+ clearTimeout(timer);
276
+ const current = generation;
277
+ try {
278
+ if (!session) {
279
+ const result = await call(`/chat/${options.chatbotId}/sessions`, { label: name.value.trim() || 'Visitor' });
280
+ if (destroyed || current !== generation) return;
281
+ if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
282
+ session = { id: result.id, token: result.token };
283
+ persist();
284
+ }
285
+ if (!pending || pending.body !== body) pending = { body, idempotencyKey: crypto.randomUUID() };
286
+ persist();
287
+ await call(`/chat/sessions/${session.id}/messages`, pending, session.token);
288
+ if (destroyed || current !== generation) return;
289
+ pending = null;
290
+ persist();
291
+ input.value = '';
292
+ failures = 0;
293
+ status.classList.remove('error');
294
+ status.textContent = 'Message sent.';
295
+ } catch (error) {
296
+ if (!destroyed && current === generation) { expire(error); report(error); }
297
+ } finally {
298
+ if (!destroyed && current === generation) {
299
+ sending = false;
300
+ controls();
301
+ // Keep send failures visible until the visitor retries; polling resumes later.
302
+ if (!pending && !terminal) await refresh();
303
+ else schedule();
304
+ if (!panel.hidden && !input.disabled) input.focus();
305
+ }
306
+ }
307
+ };
308
+ instances.set(key, widget);
309
+ return widget;
310
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@sub-zero/cocoa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Drop-in Cocoa support chat for any website, powered by SubZero.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ },
20
+ "./widget": "./dist/cocoa.js",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "unpkg": "dist/cocoa.js",
24
+ "jsdelivr": "dist/cocoa.js",
25
+ "files": [
26
+ "dist",
27
+ "README.md"
28
+ ],
29
+ "sideEffects": [
30
+ "./dist/cocoa.js"
31
+ ],
32
+ "license": "UNLICENSED",
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org/"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/thetechjd/subzero-frontend.git",
40
+ "directory": "packages/cocoa"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "scripts": {
46
+ "build": "node build.mjs",
47
+ "test": "npm run build && node --test test/package.test.mjs && node test/browser.cjs",
48
+ "prepack": "npm run build"
49
+ },
50
+ "keywords": [
51
+ "cocoa",
52
+ "subzero",
53
+ "chatbot",
54
+ "support",
55
+ "widget"
56
+ ]
57
+ }