@sub-zero/cocoa 0.1.1 → 0.2.1

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
@@ -163,3 +163,17 @@ contains only `dist`, this README, and package metadata. Do not publish the priv
163
163
  Angular application root. Review the version and `npm pack --dry-run` output before
164
164
  publishing. No open-source license is granted (`UNLICENSED`); the source repository
165
165
  may be private.
166
+
167
+ ## Incident handoff
168
+
169
+ The visible **Get further help** action opens a review form. The visitor chooses a correspondence email and explicitly submits an incident; neither opening chat nor mentioning an email creates one. The current visitor session authorizes creation in the chatbot's project. A confirmed reference survives reload, and failed submissions retain their text and idempotency key for retry. The team's follow-up is by email. The email is not verified account identity.
170
+
171
+ Requires the SubZero chat-escalation API. Closed conversations can still submit while their visitor credential is valid; paused deployments cannot accept new submissions. No model call is needed for handoff. `reset()` clears the chat, pending form and visitor credential; call it on logout/account changes. SMTP acknowledgement delivery retries are at-least-once; a stable Message-ID assists threading but cannot guarantee exactly-once delivery by an external mail server.
172
+
173
+ ### Greeting and reply feedback
174
+
175
+ The launcher shows “Hi! Ask me anything.” after five seconds, once per browser tab. Click to chat or dismiss it. Opening chat first suppresses the greeting. A soft chime plays only if a previous user interaction has enabled browser audio; otherwise the greeting appears silently. Nothing starts a conversation until the visitor sends a message or explicitly submits an incident.
176
+
177
+ Customize with `greetingMessage` (string, or `false` to disable), `greetingDelay` (milliseconds), and `greetingSound` (`false` for silent greetings). Script embeds accept `data-greeting-message`, `data-greeting-delay` and `data-greeting-sound`.
178
+
179
+ Animated dots indicate a reply is pending while the agent is online. They clear when an answer arrives, a person takes over, the chat is paused/closed, an incident is submitted, or a request fails. Reduced-motion preferences disable animation. The indicator reflects an unanswered message, not provider-level token streaming.
package/dist/cocoa.js CHANGED
@@ -23,6 +23,8 @@ function createCocoa(options = {}) {
23
23
  if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
24
24
  const pollInterval = options.pollInterval ?? 2500;
25
25
  if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
26
+ const greetingDelay = options.greetingDelay ?? 5000;
27
+ if (!Number.isFinite(greetingDelay) || greetingDelay < 0) throw new Error('greetingDelay must be a nonnegative number.');
26
28
  const host = document.createElement('div');
27
29
  host.dataset.cocoaWidget = '';
28
30
  const root = host.attachShadow({ mode: 'open' });
@@ -33,11 +35,15 @@ function createCocoa(options = {}) {
33
35
  *,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
34
36
  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}
35
37
  .launcher{display:block;margin-left:auto;width:72px;height:72px;padding:0;border:2px solid #e1f4fa;border-radius:50%;overflow:hidden;background:#92c5d5;box-shadow:0 6px 24px #0005}.launcher img{display:block;width:100%;height:100%;object-fit:cover;transform:scale(1.18);pointer-events:none}.launcher:hover{filter:brightness(1.06)}
38
+ .greeting{position:absolute;right:0;bottom:84px;display:flex;align-items:center;max-width:calc(100vw - 32px);width:250px;background:#fff5e9;color:#392516;border-radius:18px;box-shadow:0 6px 24px #0003;padding:6px}.greeting::after{content:'';position:absolute;right:28px;bottom:-8px;border:8px solid transparent;border-top-color:#fff5e9;border-bottom:0}.greeting button{color:inherit;background:transparent;border:0}.greeting .greeting-open{text-align:left;flex:1}.greeting .greeting-dismiss{padding:8px;min-width:40px;min-height:40px}
39
+ .thinking{display:flex;align-items:center;gap:8px;padding:8px 16px;color:#c8d4dd;font-size:12px}.dots{display:flex;gap:4px}.dots i{width:5px;height:5px;border-radius:50%;background:#92c5d5}
40
+ @media(prefers-reduced-motion:no-preference){.greeting{animation:appear .25s ease-out}.dots i{animation:think 1.2s ease-in-out infinite}.dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}@keyframes think{0%,60%,100%{opacity:.4;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}}
36
41
  .panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 116px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
37
42
  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}
38
43
  .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}
39
44
  .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}
40
45
  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}
46
+ .help{margin:0 16px 10px}.handoff{overflow:auto;min-height:0;flex:1}.handoff p{margin:0;color:#c8d4dd;font-size:12px}.handoff textarea{height:90px}.handoff-result{padding:12px 16px;overflow-wrap:anywhere}
41
47
  @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)}}}
42
48
  @media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
43
49
  `;
@@ -63,6 +69,32 @@ function createCocoa(options = {}) {
63
69
  actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
64
70
  composer.append(nameLabel, messageLabel, actions);
65
71
  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);
72
+ const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
73
+ const handoff = element('form', { class: 'handoff', hidden: '' });
74
+ const fields = {};
75
+ for (const [key, label, tag, max, type] of [
76
+ ['email', 'Correspondence email', 'input', 254, 'email'],
77
+ ['name', 'Your name (optional)', 'input', 120, 'text'],
78
+ ['summary', 'Summary', 'input', 200, 'text'],
79
+ ['details', 'What do you still need help with?', 'textarea', 6000, null],
80
+ ]) {
81
+ const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
82
+ const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
83
+ }
84
+ handoff.append(element('p', {}, 'Review these details before submitting. Your visible conversation will accompany the incident. The support team will follow up at this email. Never include passwords, seed phrases or private keys.'));
85
+ const handoffError = element('p', {class:'error', role:'alert'});
86
+ const back = element('button', {type:'button'}, 'Back to chat');
87
+ const submitIncident = element('button', {type:'submit'}, 'Submit incident');
88
+ const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
89
+ handoff.append(handoffError, handoffActions);
90
+ const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
91
+ panel.append(help, handoff, handoffResult);
92
+ const thinking = element('div', {class:'thinking',role:'status',hidden:''});
93
+ const dots = element('span', {class:'dots','aria-hidden':'true'});
94
+ dots.append(element('i'),element('i'),element('i'));
95
+ const thinkingLabel = element('span', {}, 'Cocoa is thinking…');
96
+ thinking.append(dots, thinkingLabel);
97
+ composer.before(thinking);
66
98
  panel.querySelector('strong').textContent = options.title || 'Cocoa support';
67
99
  const launcher = document.createElement('button');
68
100
  launcher.type = 'button';
@@ -77,7 +109,11 @@ function createCocoa(options = {}) {
77
109
  launcher.setAttribute('aria-expanded', 'false');
78
110
  launcher.setAttribute('aria-controls', panel.id);
79
111
  launcher.setAttribute('aria-haspopup', 'dialog');
80
- root.append(panel, launcher);
112
+ const greeting = element('div', {class:'greeting',hidden:''});
113
+ const greetingOpen = element('button', {type:'button',class:'greeting-open'}, options.greetingMessage || 'Hi! Ask me anything.');
114
+ const greetingDismiss = element('button', {type:'button',class:'greeting-dismiss','aria-label':'Dismiss greeting'}, '×');
115
+ greeting.append(greetingOpen,greetingDismiss);
116
+ root.append(panel, greeting, launcher);
81
117
  document.body.append(host);
82
118
  const messages = panel.querySelector('.messages');
83
119
  const status = panel.querySelector('.status');
@@ -89,12 +125,48 @@ function createCocoa(options = {}) {
89
125
  name.value = String(options.visitorLabel || '').slice(0, 120);
90
126
  let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
91
127
  let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
128
+ let incident = null, incidentPending = null, submittingIncident = false, expired = false;
129
+ let greetingTimer, audioContext, greetingSeen = false;
130
+ const greetingKey = key + ':greeted';
131
+ try { greetingSeen = sessionStorage.getItem(greetingKey) === '1'; } catch {}
132
+ const dismissGreeting = (remember = true) => {
133
+ clearTimeout(greetingTimer); greeting.hidden = true;
134
+ if (remember) { greetingSeen = true; try { sessionStorage.setItem(greetingKey,'1'); } catch {} }
135
+ document.removeEventListener('pointerdown', unlockAudio);
136
+ document.removeEventListener('keydown', unlockAudio);
137
+ if (audioContext) { void audioContext.close().catch(()=>{}); audioContext = null; }
138
+ };
139
+ function unlockAudio() {
140
+ if (destroyed || greetingSeen || options.greetingSound === false || audioContext) return;
141
+ try {
142
+ const Audio = window.AudioContext || window.webkitAudioContext;
143
+ if (Audio) { audioContext = new Audio(); void audioContext.resume().catch(()=>{}); }
144
+ } catch { /* Greeting still works when audio is unavailable. */ }
145
+ }
146
+ function showGreeting() {
147
+ if (destroyed || greetingSeen || !panel.hidden || session || options.greetingMessage === false || document.hidden) return;
148
+ greeting.hidden = false; greetingSeen = true;
149
+ try { sessionStorage.setItem(greetingKey,'1'); } catch {}
150
+ document.removeEventListener('pointerdown', unlockAudio);
151
+ document.removeEventListener('keydown', unlockAudio);
152
+ // Only play after a prior user gesture unlocked audio. Never queue a surprise sound.
153
+ if (audioContext?.state === 'running') {
154
+ try {
155
+ const tone = audioContext.createOscillator(), volume = audioContext.createGain();
156
+ const now = audioContext.currentTime;
157
+ tone.type = 'sine'; tone.frequency.setValueAtTime(660,now); tone.frequency.exponentialRampToValueAtTime(880,now+.1);
158
+ volume.gain.setValueAtTime(0,now); volume.gain.linearRampToValueAtTime(.045,now+.015); volume.gain.exponentialRampToValueAtTime(.001,now+.2);
159
+ tone.connect(volume); volume.connect(audioContext.destination); tone.start(now); tone.stop(now+.22);
160
+ tone.onended = () => { tone.disconnect(); volume.disconnect(); if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } };
161
+ } catch { if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } }
162
+ } else if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; }
163
+ }
92
164
  const rendered = new Set();
93
165
  const requests = new Set();
94
166
  const persist = () => {
95
167
  if (options.persistSession === false) return;
96
168
  try {
97
- if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
169
+ if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
98
170
  else sessionStorage.removeItem(key);
99
171
  } catch { /* Storage may be unavailable; an in-memory session still works. */ }
100
172
  };
@@ -103,6 +175,11 @@ function createCocoa(options = {}) {
103
175
  const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
104
176
  if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
105
177
  session = { id: stored.id, token: stored.token };
178
+ const saved = stored.incidentPending;
179
+ if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
180
+ incidentPending = saved;
181
+ for (const k of Object.keys(fields)) fields[k].value = saved[k];
182
+ }
106
183
  if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
107
184
  pending = stored.pending;
108
185
  input.value = pending.body;
@@ -119,7 +196,12 @@ function createCocoa(options = {}) {
119
196
  welcome();
120
197
  const controls = () => {
121
198
  const focusWasInComposer = root.activeElement === input || root.activeElement === send;
122
- input.disabled = sending || terminal || paused;
199
+ if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
200
+ help.disabled = sending || submittingIncident || expired || paused;
201
+ help.hidden = !!incident || !handoff.hidden;
202
+ handoffResult.hidden = !incident;
203
+ if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
204
+ input.disabled = sending || terminal || paused || submittingIncident;
123
205
  send.disabled = sending || terminal || paused;
124
206
  name.disabled = sending;
125
207
  fresh.hidden = !terminal;
@@ -131,6 +213,7 @@ function createCocoa(options = {}) {
131
213
  };
132
214
  controls();
133
215
  const report = (error) => {
216
+ thinking.hidden = true;
134
217
  status.classList.add('error');
135
218
  status.textContent = error.message;
136
219
  try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
@@ -165,7 +248,7 @@ function createCocoa(options = {}) {
165
248
  }
166
249
  function expire(error) {
167
250
  if (error.status === 401 || error.status === 404) {
168
- terminal = true;
251
+ terminal = true; expired = true;
169
252
  session = null;
170
253
  pending = null;
171
254
  persist();
@@ -192,12 +275,15 @@ function createCocoa(options = {}) {
192
275
  if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
193
276
  version = data.version;
194
277
  }
278
+ incident = data.escalation || incident;
195
279
  terminal = data.state === 'closed';
196
280
  paused = !data.enabled;
197
281
  status.classList.remove('error');
198
- status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
282
+ status.textContent = incident ? 'Your incident is with the support team. Follow-up will be by email.' : terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
199
283
  data.state === 'human' ? 'A person is handling your conversation.' :
200
284
  data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
285
+ const last = data.messages.filter(m=>['customer','agent','human'].includes(m.actor)).at(-1);
286
+ thinking.hidden = !(last?.actor === 'customer' && data.agentOnline && data.state === 'agent' && !terminal && !paused && !incident && handoff.hidden);
201
287
  controls();
202
288
  }
203
289
  function schedule() {
@@ -207,7 +293,7 @@ function createCocoa(options = {}) {
207
293
  }
208
294
  }
209
295
  async function refresh() {
210
- if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
296
+ if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
211
297
  refreshing = true;
212
298
  const current = generation;
213
299
  try {
@@ -230,18 +316,25 @@ function createCocoa(options = {}) {
230
316
  }
231
317
  function open() {
232
318
  if (destroyed) return;
319
+ dismissGreeting();
233
320
  panel.hidden = false;
234
321
  launcher.setAttribute('aria-expanded', 'true');
235
- (terminal ? fresh : input).focus();
322
+ (!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
236
323
  void refresh();
237
324
  }
238
325
  function reset() {
239
326
  if (destroyed) return;
240
327
  generation++;
241
328
  sending = refreshing = false;
329
+ thinking.hidden = true;
242
330
  for (const request of requests) request.abort();
243
331
  clearTimeout(timer);
244
- session = pending = null;
332
+ session = pending = incident = incidentPending = null;
333
+ expired = submittingIncident = false;
334
+ handoff.hidden = true; composer.hidden = messages.hidden = false;
335
+ submitIncident.disabled = back.disabled = false;
336
+ for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
337
+ handoffError.textContent = '';
245
338
  terminal = paused = false;
246
339
  version = -1;
247
340
  failures = 0;
@@ -256,12 +349,13 @@ function createCocoa(options = {}) {
256
349
  }
257
350
  const visibility = () => {
258
351
  clearTimeout(timer);
259
- if (!document.hidden) void refresh();
352
+ if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
260
353
  };
261
354
  document.addEventListener('visibilitychange', visibility);
262
355
  const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
263
356
  if (destroyed) return;
264
357
  destroyed = true;
358
+ dismissGreeting(false);
265
359
  generation++;
266
360
  clearTimeout(timer);
267
361
  for (const request of requests) request.abort();
@@ -270,6 +364,8 @@ function createCocoa(options = {}) {
270
364
  instances.delete(key);
271
365
  } };
272
366
  launcher.onclick = widget.toggle;
367
+ greetingOpen.onclick = open;
368
+ greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
273
369
  panel.querySelector('header button').onclick = close;
274
370
  fresh.onclick = reset;
275
371
  root.addEventListener('keydown', (event) => {
@@ -278,8 +374,9 @@ function createCocoa(options = {}) {
278
374
  form.onsubmit = async (event) => {
279
375
  event.preventDefault();
280
376
  const body = input.value.trim();
281
- if (!body || sending || terminal || paused || destroyed) return;
377
+ if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
282
378
  sending = true;
379
+ thinking.hidden = true;
283
380
  controls();
284
381
  clearTimeout(timer);
285
382
  const current = generation;
@@ -314,6 +411,60 @@ function createCocoa(options = {}) {
314
411
  }
315
412
  }
316
413
  };
414
+ function showChat() {
415
+ handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
416
+ if (!panel.hidden) (input.disabled ? help : input).focus();
417
+ }
418
+ help.onclick = () => {
419
+ if (expired || paused || incident || sending || submittingIncident) return;
420
+ const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
421
+ if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
422
+ if (!fields.details.value) fields.details.value = last || '';
423
+ if (!fields.name.value) fields.name.value = name.value;
424
+ thinking.hidden = true;
425
+ handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
426
+ };
427
+ back.onclick = showChat;
428
+ handoff.onsubmit = async event => {
429
+ event.preventDefault();
430
+ if (submittingIncident || sending || expired || paused || incident || destroyed) return;
431
+ const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
432
+ if (!data.summary || !data.details || !data.email) return;
433
+ submittingIncident = true; submitIncident.disabled = back.disabled = true;
434
+ for(const field of Object.values(fields)) field.disabled = true;
435
+ handoffError.textContent = ''; controls();
436
+ const current = generation;
437
+ try {
438
+ if (!session) {
439
+ const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
440
+ if (destroyed || current !== generation) return;
441
+ if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
442
+ session = {id:result.id,token:result.token};
443
+ }
444
+ if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
445
+ persist();
446
+ const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
447
+ if (destroyed || current !== generation) return;
448
+ if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
449
+ incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
450
+ } catch(error) {
451
+ if (!destroyed && current === generation) {
452
+ expire(error);
453
+ handoffError.textContent = expired ? 'This session expired. Return to chat and explicitly start a new conversation before submitting.' : 'Submission could not be confirmed. Your details are kept here; retry to check or submit the same incident.';
454
+ }
455
+ } finally {
456
+ if (!destroyed && current === generation) {
457
+ submittingIncident = false; submitIncident.disabled = back.disabled = false;
458
+ for(const field of Object.values(fields)) field.disabled = false;
459
+ controls(); schedule();
460
+ }
461
+ }
462
+ };
463
+ if (!greetingSeen && !session && options.greetingMessage !== false) {
464
+ document.addEventListener('pointerdown', unlockAudio);
465
+ document.addEventListener('keydown', unlockAudio);
466
+ greetingTimer=setTimeout(showGreeting,greetingDelay);
467
+ }
317
468
  instances.set(key, widget);
318
469
  return widget;
319
470
  }
@@ -328,6 +479,9 @@ if (script?.dataset.chatbot) {
328
479
  launcherLabel: script.dataset.launcherLabel,
329
480
  accentColor: script.dataset.accentColor,
330
481
  nonce: script.nonce,
482
+ greetingMessage: script.dataset.greetingMessage === 'false' ? false : script.dataset.greetingMessage,
483
+ greetingDelay: script.dataset.greetingDelay === undefined ? undefined : Number(script.dataset.greetingDelay),
484
+ greetingSound: script.dataset.greetingSound !== 'false',
331
485
  };
332
486
  const mount = () => { window.Cocoa.widget = createCocoa(options); };
333
487
  if (document.body) mount();
package/dist/index.cjs CHANGED
@@ -21,6 +21,8 @@ function createCocoa(options = {}) {
21
21
  if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
22
22
  const pollInterval = options.pollInterval ?? 2500;
23
23
  if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
24
+ const greetingDelay = options.greetingDelay ?? 5000;
25
+ if (!Number.isFinite(greetingDelay) || greetingDelay < 0) throw new Error('greetingDelay must be a nonnegative number.');
24
26
  const host = document.createElement('div');
25
27
  host.dataset.cocoaWidget = '';
26
28
  const root = host.attachShadow({ mode: 'open' });
@@ -31,11 +33,15 @@ function createCocoa(options = {}) {
31
33
  *,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
32
34
  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}
33
35
  .launcher{display:block;margin-left:auto;width:72px;height:72px;padding:0;border:2px solid #e1f4fa;border-radius:50%;overflow:hidden;background:#92c5d5;box-shadow:0 6px 24px #0005}.launcher img{display:block;width:100%;height:100%;object-fit:cover;transform:scale(1.18);pointer-events:none}.launcher:hover{filter:brightness(1.06)}
36
+ .greeting{position:absolute;right:0;bottom:84px;display:flex;align-items:center;max-width:calc(100vw - 32px);width:250px;background:#fff5e9;color:#392516;border-radius:18px;box-shadow:0 6px 24px #0003;padding:6px}.greeting::after{content:'';position:absolute;right:28px;bottom:-8px;border:8px solid transparent;border-top-color:#fff5e9;border-bottom:0}.greeting button{color:inherit;background:transparent;border:0}.greeting .greeting-open{text-align:left;flex:1}.greeting .greeting-dismiss{padding:8px;min-width:40px;min-height:40px}
37
+ .thinking{display:flex;align-items:center;gap:8px;padding:8px 16px;color:#c8d4dd;font-size:12px}.dots{display:flex;gap:4px}.dots i{width:5px;height:5px;border-radius:50%;background:#92c5d5}
38
+ @media(prefers-reduced-motion:no-preference){.greeting{animation:appear .25s ease-out}.dots i{animation:think 1.2s ease-in-out infinite}.dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}@keyframes think{0%,60%,100%{opacity:.4;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}}
34
39
  .panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 116px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
35
40
  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}
36
41
  .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}
37
42
  .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}
38
43
  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}
44
+ .help{margin:0 16px 10px}.handoff{overflow:auto;min-height:0;flex:1}.handoff p{margin:0;color:#c8d4dd;font-size:12px}.handoff textarea{height:90px}.handoff-result{padding:12px 16px;overflow-wrap:anywhere}
39
45
  @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)}}}
40
46
  @media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
41
47
  `;
@@ -61,6 +67,32 @@ function createCocoa(options = {}) {
61
67
  actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
62
68
  composer.append(nameLabel, messageLabel, actions);
63
69
  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);
70
+ const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
71
+ const handoff = element('form', { class: 'handoff', hidden: '' });
72
+ const fields = {};
73
+ for (const [key, label, tag, max, type] of [
74
+ ['email', 'Correspondence email', 'input', 254, 'email'],
75
+ ['name', 'Your name (optional)', 'input', 120, 'text'],
76
+ ['summary', 'Summary', 'input', 200, 'text'],
77
+ ['details', 'What do you still need help with?', 'textarea', 6000, null],
78
+ ]) {
79
+ const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
80
+ const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
81
+ }
82
+ handoff.append(element('p', {}, 'Review these details before submitting. Your visible conversation will accompany the incident. The support team will follow up at this email. Never include passwords, seed phrases or private keys.'));
83
+ const handoffError = element('p', {class:'error', role:'alert'});
84
+ const back = element('button', {type:'button'}, 'Back to chat');
85
+ const submitIncident = element('button', {type:'submit'}, 'Submit incident');
86
+ const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
87
+ handoff.append(handoffError, handoffActions);
88
+ const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
89
+ panel.append(help, handoff, handoffResult);
90
+ const thinking = element('div', {class:'thinking',role:'status',hidden:''});
91
+ const dots = element('span', {class:'dots','aria-hidden':'true'});
92
+ dots.append(element('i'),element('i'),element('i'));
93
+ const thinkingLabel = element('span', {}, 'Cocoa is thinking…');
94
+ thinking.append(dots, thinkingLabel);
95
+ composer.before(thinking);
64
96
  panel.querySelector('strong').textContent = options.title || 'Cocoa support';
65
97
  const launcher = document.createElement('button');
66
98
  launcher.type = 'button';
@@ -75,7 +107,11 @@ function createCocoa(options = {}) {
75
107
  launcher.setAttribute('aria-expanded', 'false');
76
108
  launcher.setAttribute('aria-controls', panel.id);
77
109
  launcher.setAttribute('aria-haspopup', 'dialog');
78
- root.append(panel, launcher);
110
+ const greeting = element('div', {class:'greeting',hidden:''});
111
+ const greetingOpen = element('button', {type:'button',class:'greeting-open'}, options.greetingMessage || 'Hi! Ask me anything.');
112
+ const greetingDismiss = element('button', {type:'button',class:'greeting-dismiss','aria-label':'Dismiss greeting'}, '×');
113
+ greeting.append(greetingOpen,greetingDismiss);
114
+ root.append(panel, greeting, launcher);
79
115
  document.body.append(host);
80
116
  const messages = panel.querySelector('.messages');
81
117
  const status = panel.querySelector('.status');
@@ -87,12 +123,48 @@ function createCocoa(options = {}) {
87
123
  name.value = String(options.visitorLabel || '').slice(0, 120);
88
124
  let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
89
125
  let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
126
+ let incident = null, incidentPending = null, submittingIncident = false, expired = false;
127
+ let greetingTimer, audioContext, greetingSeen = false;
128
+ const greetingKey = key + ':greeted';
129
+ try { greetingSeen = sessionStorage.getItem(greetingKey) === '1'; } catch {}
130
+ const dismissGreeting = (remember = true) => {
131
+ clearTimeout(greetingTimer); greeting.hidden = true;
132
+ if (remember) { greetingSeen = true; try { sessionStorage.setItem(greetingKey,'1'); } catch {} }
133
+ document.removeEventListener('pointerdown', unlockAudio);
134
+ document.removeEventListener('keydown', unlockAudio);
135
+ if (audioContext) { void audioContext.close().catch(()=>{}); audioContext = null; }
136
+ };
137
+ function unlockAudio() {
138
+ if (destroyed || greetingSeen || options.greetingSound === false || audioContext) return;
139
+ try {
140
+ const Audio = window.AudioContext || window.webkitAudioContext;
141
+ if (Audio) { audioContext = new Audio(); void audioContext.resume().catch(()=>{}); }
142
+ } catch { /* Greeting still works when audio is unavailable. */ }
143
+ }
144
+ function showGreeting() {
145
+ if (destroyed || greetingSeen || !panel.hidden || session || options.greetingMessage === false || document.hidden) return;
146
+ greeting.hidden = false; greetingSeen = true;
147
+ try { sessionStorage.setItem(greetingKey,'1'); } catch {}
148
+ document.removeEventListener('pointerdown', unlockAudio);
149
+ document.removeEventListener('keydown', unlockAudio);
150
+ // Only play after a prior user gesture unlocked audio. Never queue a surprise sound.
151
+ if (audioContext?.state === 'running') {
152
+ try {
153
+ const tone = audioContext.createOscillator(), volume = audioContext.createGain();
154
+ const now = audioContext.currentTime;
155
+ tone.type = 'sine'; tone.frequency.setValueAtTime(660,now); tone.frequency.exponentialRampToValueAtTime(880,now+.1);
156
+ volume.gain.setValueAtTime(0,now); volume.gain.linearRampToValueAtTime(.045,now+.015); volume.gain.exponentialRampToValueAtTime(.001,now+.2);
157
+ tone.connect(volume); volume.connect(audioContext.destination); tone.start(now); tone.stop(now+.22);
158
+ tone.onended = () => { tone.disconnect(); volume.disconnect(); if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } };
159
+ } catch { if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } }
160
+ } else if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; }
161
+ }
90
162
  const rendered = new Set();
91
163
  const requests = new Set();
92
164
  const persist = () => {
93
165
  if (options.persistSession === false) return;
94
166
  try {
95
- if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
167
+ if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
96
168
  else sessionStorage.removeItem(key);
97
169
  } catch { /* Storage may be unavailable; an in-memory session still works. */ }
98
170
  };
@@ -101,6 +173,11 @@ function createCocoa(options = {}) {
101
173
  const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
102
174
  if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
103
175
  session = { id: stored.id, token: stored.token };
176
+ const saved = stored.incidentPending;
177
+ if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
178
+ incidentPending = saved;
179
+ for (const k of Object.keys(fields)) fields[k].value = saved[k];
180
+ }
104
181
  if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
105
182
  pending = stored.pending;
106
183
  input.value = pending.body;
@@ -117,7 +194,12 @@ function createCocoa(options = {}) {
117
194
  welcome();
118
195
  const controls = () => {
119
196
  const focusWasInComposer = root.activeElement === input || root.activeElement === send;
120
- input.disabled = sending || terminal || paused;
197
+ if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
198
+ help.disabled = sending || submittingIncident || expired || paused;
199
+ help.hidden = !!incident || !handoff.hidden;
200
+ handoffResult.hidden = !incident;
201
+ if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
202
+ input.disabled = sending || terminal || paused || submittingIncident;
121
203
  send.disabled = sending || terminal || paused;
122
204
  name.disabled = sending;
123
205
  fresh.hidden = !terminal;
@@ -129,6 +211,7 @@ function createCocoa(options = {}) {
129
211
  };
130
212
  controls();
131
213
  const report = (error) => {
214
+ thinking.hidden = true;
132
215
  status.classList.add('error');
133
216
  status.textContent = error.message;
134
217
  try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
@@ -163,7 +246,7 @@ function createCocoa(options = {}) {
163
246
  }
164
247
  function expire(error) {
165
248
  if (error.status === 401 || error.status === 404) {
166
- terminal = true;
249
+ terminal = true; expired = true;
167
250
  session = null;
168
251
  pending = null;
169
252
  persist();
@@ -190,12 +273,15 @@ function createCocoa(options = {}) {
190
273
  if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
191
274
  version = data.version;
192
275
  }
276
+ incident = data.escalation || incident;
193
277
  terminal = data.state === 'closed';
194
278
  paused = !data.enabled;
195
279
  status.classList.remove('error');
196
- status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
280
+ status.textContent = incident ? 'Your incident is with the support team. Follow-up will be by email.' : terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
197
281
  data.state === 'human' ? 'A person is handling your conversation.' :
198
282
  data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
283
+ const last = data.messages.filter(m=>['customer','agent','human'].includes(m.actor)).at(-1);
284
+ thinking.hidden = !(last?.actor === 'customer' && data.agentOnline && data.state === 'agent' && !terminal && !paused && !incident && handoff.hidden);
199
285
  controls();
200
286
  }
201
287
  function schedule() {
@@ -205,7 +291,7 @@ function createCocoa(options = {}) {
205
291
  }
206
292
  }
207
293
  async function refresh() {
208
- if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
294
+ if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
209
295
  refreshing = true;
210
296
  const current = generation;
211
297
  try {
@@ -228,18 +314,25 @@ function createCocoa(options = {}) {
228
314
  }
229
315
  function open() {
230
316
  if (destroyed) return;
317
+ dismissGreeting();
231
318
  panel.hidden = false;
232
319
  launcher.setAttribute('aria-expanded', 'true');
233
- (terminal ? fresh : input).focus();
320
+ (!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
234
321
  void refresh();
235
322
  }
236
323
  function reset() {
237
324
  if (destroyed) return;
238
325
  generation++;
239
326
  sending = refreshing = false;
327
+ thinking.hidden = true;
240
328
  for (const request of requests) request.abort();
241
329
  clearTimeout(timer);
242
- session = pending = null;
330
+ session = pending = incident = incidentPending = null;
331
+ expired = submittingIncident = false;
332
+ handoff.hidden = true; composer.hidden = messages.hidden = false;
333
+ submitIncident.disabled = back.disabled = false;
334
+ for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
335
+ handoffError.textContent = '';
243
336
  terminal = paused = false;
244
337
  version = -1;
245
338
  failures = 0;
@@ -254,12 +347,13 @@ function createCocoa(options = {}) {
254
347
  }
255
348
  const visibility = () => {
256
349
  clearTimeout(timer);
257
- if (!document.hidden) void refresh();
350
+ if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
258
351
  };
259
352
  document.addEventListener('visibilitychange', visibility);
260
353
  const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
261
354
  if (destroyed) return;
262
355
  destroyed = true;
356
+ dismissGreeting(false);
263
357
  generation++;
264
358
  clearTimeout(timer);
265
359
  for (const request of requests) request.abort();
@@ -268,6 +362,8 @@ function createCocoa(options = {}) {
268
362
  instances.delete(key);
269
363
  } };
270
364
  launcher.onclick = widget.toggle;
365
+ greetingOpen.onclick = open;
366
+ greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
271
367
  panel.querySelector('header button').onclick = close;
272
368
  fresh.onclick = reset;
273
369
  root.addEventListener('keydown', (event) => {
@@ -276,8 +372,9 @@ function createCocoa(options = {}) {
276
372
  form.onsubmit = async (event) => {
277
373
  event.preventDefault();
278
374
  const body = input.value.trim();
279
- if (!body || sending || terminal || paused || destroyed) return;
375
+ if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
280
376
  sending = true;
377
+ thinking.hidden = true;
281
378
  controls();
282
379
  clearTimeout(timer);
283
380
  const current = generation;
@@ -312,6 +409,60 @@ function createCocoa(options = {}) {
312
409
  }
313
410
  }
314
411
  };
412
+ function showChat() {
413
+ handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
414
+ if (!panel.hidden) (input.disabled ? help : input).focus();
415
+ }
416
+ help.onclick = () => {
417
+ if (expired || paused || incident || sending || submittingIncident) return;
418
+ const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
419
+ if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
420
+ if (!fields.details.value) fields.details.value = last || '';
421
+ if (!fields.name.value) fields.name.value = name.value;
422
+ thinking.hidden = true;
423
+ handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
424
+ };
425
+ back.onclick = showChat;
426
+ handoff.onsubmit = async event => {
427
+ event.preventDefault();
428
+ if (submittingIncident || sending || expired || paused || incident || destroyed) return;
429
+ const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
430
+ if (!data.summary || !data.details || !data.email) return;
431
+ submittingIncident = true; submitIncident.disabled = back.disabled = true;
432
+ for(const field of Object.values(fields)) field.disabled = true;
433
+ handoffError.textContent = ''; controls();
434
+ const current = generation;
435
+ try {
436
+ if (!session) {
437
+ const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
438
+ if (destroyed || current !== generation) return;
439
+ if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
440
+ session = {id:result.id,token:result.token};
441
+ }
442
+ if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
443
+ persist();
444
+ const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
445
+ if (destroyed || current !== generation) return;
446
+ if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
447
+ incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
448
+ } catch(error) {
449
+ if (!destroyed && current === generation) {
450
+ expire(error);
451
+ handoffError.textContent = expired ? 'This session expired. Return to chat and explicitly start a new conversation before submitting.' : 'Submission could not be confirmed. Your details are kept here; retry to check or submit the same incident.';
452
+ }
453
+ } finally {
454
+ if (!destroyed && current === generation) {
455
+ submittingIncident = false; submitIncident.disabled = back.disabled = false;
456
+ for(const field of Object.values(fields)) field.disabled = false;
457
+ controls(); schedule();
458
+ }
459
+ }
460
+ };
461
+ if (!greetingSeen && !session && options.greetingMessage !== false) {
462
+ document.addEventListener('pointerdown', unlockAudio);
463
+ document.addEventListener('keydown', unlockAudio);
464
+ greetingTimer=setTimeout(showGreeting,greetingDelay);
465
+ }
315
466
  instances.set(key, widget);
316
467
  return widget;
317
468
  }
package/dist/index.d.cts CHANGED
@@ -7,6 +7,12 @@ export interface CocoaOptions {
7
7
  /** Accessible name and hover tooltip for the circular Cocoa portrait button. */
8
8
  launcherLabel?: string;
9
9
  welcomeMessage?: string;
10
+ /** Once-per-tab launcher greeting; false disables it. Default: Hi! Ask me anything. */
11
+ greetingMessage?: string | false;
12
+ /** Delay before greeting, in milliseconds. Defaults to 5000. */
13
+ greetingDelay?: number;
14
+ /** Soft chime if prior interaction permits audio. Defaults to true. */
15
+ greetingSound?: boolean;
10
16
  /** Unverified display name, never an authentication credential. */
11
17
  visitorLabel?: string;
12
18
  /** Six-digit hex color, e.g. #385b50. */
package/dist/index.d.ts CHANGED
@@ -7,6 +7,12 @@ export interface CocoaOptions {
7
7
  /** Accessible name and hover tooltip for the circular Cocoa portrait button. */
8
8
  launcherLabel?: string;
9
9
  welcomeMessage?: string;
10
+ /** Once-per-tab launcher greeting; false disables it. Default: Hi! Ask me anything. */
11
+ greetingMessage?: string | false;
12
+ /** Delay before greeting, in milliseconds. Defaults to 5000. */
13
+ greetingDelay?: number;
14
+ /** Soft chime if prior interaction permits audio. Defaults to true. */
15
+ greetingSound?: boolean;
10
16
  /** Unverified display name, never an authentication credential. */
11
17
  visitorLabel?: string;
12
18
  /** Six-digit hex color, e.g. #385b50. */
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ export function createCocoa(options = {}) {
21
21
  if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
22
22
  const pollInterval = options.pollInterval ?? 2500;
23
23
  if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
24
+ const greetingDelay = options.greetingDelay ?? 5000;
25
+ if (!Number.isFinite(greetingDelay) || greetingDelay < 0) throw new Error('greetingDelay must be a nonnegative number.');
24
26
  const host = document.createElement('div');
25
27
  host.dataset.cocoaWidget = '';
26
28
  const root = host.attachShadow({ mode: 'open' });
@@ -31,11 +33,15 @@ export function createCocoa(options = {}) {
31
33
  *,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
32
34
  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}
33
35
  .launcher{display:block;margin-left:auto;width:72px;height:72px;padding:0;border:2px solid #e1f4fa;border-radius:50%;overflow:hidden;background:#92c5d5;box-shadow:0 6px 24px #0005}.launcher img{display:block;width:100%;height:100%;object-fit:cover;transform:scale(1.18);pointer-events:none}.launcher:hover{filter:brightness(1.06)}
36
+ .greeting{position:absolute;right:0;bottom:84px;display:flex;align-items:center;max-width:calc(100vw - 32px);width:250px;background:#fff5e9;color:#392516;border-radius:18px;box-shadow:0 6px 24px #0003;padding:6px}.greeting::after{content:'';position:absolute;right:28px;bottom:-8px;border:8px solid transparent;border-top-color:#fff5e9;border-bottom:0}.greeting button{color:inherit;background:transparent;border:0}.greeting .greeting-open{text-align:left;flex:1}.greeting .greeting-dismiss{padding:8px;min-width:40px;min-height:40px}
37
+ .thinking{display:flex;align-items:center;gap:8px;padding:8px 16px;color:#c8d4dd;font-size:12px}.dots{display:flex;gap:4px}.dots i{width:5px;height:5px;border-radius:50%;background:#92c5d5}
38
+ @media(prefers-reduced-motion:no-preference){.greeting{animation:appear .25s ease-out}.dots i{animation:think 1.2s ease-in-out infinite}.dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}@keyframes think{0%,60%,100%{opacity:.4;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}}
34
39
  .panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 116px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
35
40
  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}
36
41
  .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}
37
42
  .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}
38
43
  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}
44
+ .help{margin:0 16px 10px}.handoff{overflow:auto;min-height:0;flex:1}.handoff p{margin:0;color:#c8d4dd;font-size:12px}.handoff textarea{height:90px}.handoff-result{padding:12px 16px;overflow-wrap:anywhere}
39
45
  @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)}}}
40
46
  @media(max-height:540px){.panel{height:calc(100dvh - 116px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
41
47
  `;
@@ -61,6 +67,32 @@ export function createCocoa(options = {}) {
61
67
  actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
62
68
  composer.append(nameLabel, messageLabel, actions);
63
69
  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);
70
+ const help = element('button', { type: 'button', class: 'help' }, 'Get further help');
71
+ const handoff = element('form', { class: 'handoff', hidden: '' });
72
+ const fields = {};
73
+ for (const [key, label, tag, max, type] of [
74
+ ['email', 'Correspondence email', 'input', 254, 'email'],
75
+ ['name', 'Your name (optional)', 'input', 120, 'text'],
76
+ ['summary', 'Summary', 'input', 200, 'text'],
77
+ ['details', 'What do you still need help with?', 'textarea', 6000, null],
78
+ ]) {
79
+ const field = element(tag, { maxlength: String(max), ...(type ? {type} : {}), ...(key !== 'name' ? {required:''} : {}) });
80
+ const labelNode = element('label', {}, label); labelNode.append(field); handoff.append(labelNode); fields[key] = field;
81
+ }
82
+ handoff.append(element('p', {}, 'Review these details before submitting. Your visible conversation will accompany the incident. The support team will follow up at this email. Never include passwords, seed phrases or private keys.'));
83
+ const handoffError = element('p', {class:'error', role:'alert'});
84
+ const back = element('button', {type:'button'}, 'Back to chat');
85
+ const submitIncident = element('button', {type:'submit'}, 'Submit incident');
86
+ const handoffActions = element('div', {class:'actions'}); handoffActions.append(back, submitIncident);
87
+ handoff.append(handoffError, handoffActions);
88
+ const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
89
+ panel.append(help, handoff, handoffResult);
90
+ const thinking = element('div', {class:'thinking',role:'status',hidden:''});
91
+ const dots = element('span', {class:'dots','aria-hidden':'true'});
92
+ dots.append(element('i'),element('i'),element('i'));
93
+ const thinkingLabel = element('span', {}, 'Cocoa is thinking…');
94
+ thinking.append(dots, thinkingLabel);
95
+ composer.before(thinking);
64
96
  panel.querySelector('strong').textContent = options.title || 'Cocoa support';
65
97
  const launcher = document.createElement('button');
66
98
  launcher.type = 'button';
@@ -75,7 +107,11 @@ export function createCocoa(options = {}) {
75
107
  launcher.setAttribute('aria-expanded', 'false');
76
108
  launcher.setAttribute('aria-controls', panel.id);
77
109
  launcher.setAttribute('aria-haspopup', 'dialog');
78
- root.append(panel, launcher);
110
+ const greeting = element('div', {class:'greeting',hidden:''});
111
+ const greetingOpen = element('button', {type:'button',class:'greeting-open'}, options.greetingMessage || 'Hi! Ask me anything.');
112
+ const greetingDismiss = element('button', {type:'button',class:'greeting-dismiss','aria-label':'Dismiss greeting'}, '×');
113
+ greeting.append(greetingOpen,greetingDismiss);
114
+ root.append(panel, greeting, launcher);
79
115
  document.body.append(host);
80
116
  const messages = panel.querySelector('.messages');
81
117
  const status = panel.querySelector('.status');
@@ -87,12 +123,48 @@ export function createCocoa(options = {}) {
87
123
  name.value = String(options.visitorLabel || '').slice(0, 120);
88
124
  let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
89
125
  let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
126
+ let incident = null, incidentPending = null, submittingIncident = false, expired = false;
127
+ let greetingTimer, audioContext, greetingSeen = false;
128
+ const greetingKey = key + ':greeted';
129
+ try { greetingSeen = sessionStorage.getItem(greetingKey) === '1'; } catch {}
130
+ const dismissGreeting = (remember = true) => {
131
+ clearTimeout(greetingTimer); greeting.hidden = true;
132
+ if (remember) { greetingSeen = true; try { sessionStorage.setItem(greetingKey,'1'); } catch {} }
133
+ document.removeEventListener('pointerdown', unlockAudio);
134
+ document.removeEventListener('keydown', unlockAudio);
135
+ if (audioContext) { void audioContext.close().catch(()=>{}); audioContext = null; }
136
+ };
137
+ function unlockAudio() {
138
+ if (destroyed || greetingSeen || options.greetingSound === false || audioContext) return;
139
+ try {
140
+ const Audio = window.AudioContext || window.webkitAudioContext;
141
+ if (Audio) { audioContext = new Audio(); void audioContext.resume().catch(()=>{}); }
142
+ } catch { /* Greeting still works when audio is unavailable. */ }
143
+ }
144
+ function showGreeting() {
145
+ if (destroyed || greetingSeen || !panel.hidden || session || options.greetingMessage === false || document.hidden) return;
146
+ greeting.hidden = false; greetingSeen = true;
147
+ try { sessionStorage.setItem(greetingKey,'1'); } catch {}
148
+ document.removeEventListener('pointerdown', unlockAudio);
149
+ document.removeEventListener('keydown', unlockAudio);
150
+ // Only play after a prior user gesture unlocked audio. Never queue a surprise sound.
151
+ if (audioContext?.state === 'running') {
152
+ try {
153
+ const tone = audioContext.createOscillator(), volume = audioContext.createGain();
154
+ const now = audioContext.currentTime;
155
+ tone.type = 'sine'; tone.frequency.setValueAtTime(660,now); tone.frequency.exponentialRampToValueAtTime(880,now+.1);
156
+ volume.gain.setValueAtTime(0,now); volume.gain.linearRampToValueAtTime(.045,now+.015); volume.gain.exponentialRampToValueAtTime(.001,now+.2);
157
+ tone.connect(volume); volume.connect(audioContext.destination); tone.start(now); tone.stop(now+.22);
158
+ tone.onended = () => { tone.disconnect(); volume.disconnect(); if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } };
159
+ } catch { if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; } }
160
+ } else if (audioContext) { void audioContext.close().catch(()=>{}); audioContext=null; }
161
+ }
90
162
  const rendered = new Set();
91
163
  const requests = new Set();
92
164
  const persist = () => {
93
165
  if (options.persistSession === false) return;
94
166
  try {
95
- if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
167
+ if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending, incidentPending }));
96
168
  else sessionStorage.removeItem(key);
97
169
  } catch { /* Storage may be unavailable; an in-memory session still works. */ }
98
170
  };
@@ -101,6 +173,11 @@ export function createCocoa(options = {}) {
101
173
  const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
102
174
  if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
103
175
  session = { id: stored.id, token: stored.token };
176
+ const saved = stored.incidentPending;
177
+ if (saved && uuid.test(saved.idempotencyKey || '') && ['email','name','summary','details'].every(k=>typeof saved[k]==='string' && saved[k].length<=Number(fields[k].maxLength))) {
178
+ incidentPending = saved;
179
+ for (const k of Object.keys(fields)) fields[k].value = saved[k];
180
+ }
104
181
  if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
105
182
  pending = stored.pending;
106
183
  input.value = pending.body;
@@ -117,7 +194,12 @@ export function createCocoa(options = {}) {
117
194
  welcome();
118
195
  const controls = () => {
119
196
  const focusWasInComposer = root.activeElement === input || root.activeElement === send;
120
- input.disabled = sending || terminal || paused;
197
+ if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
198
+ help.disabled = sending || submittingIncident || expired || paused;
199
+ help.hidden = !!incident || !handoff.hidden;
200
+ handoffResult.hidden = !incident;
201
+ if (incident) handoffResult.textContent = `Incident ${incident.reference} submitted. Follow-up will be by email at ${incident.email}.`;
202
+ input.disabled = sending || terminal || paused || submittingIncident;
121
203
  send.disabled = sending || terminal || paused;
122
204
  name.disabled = sending;
123
205
  fresh.hidden = !terminal;
@@ -129,6 +211,7 @@ export function createCocoa(options = {}) {
129
211
  };
130
212
  controls();
131
213
  const report = (error) => {
214
+ thinking.hidden = true;
132
215
  status.classList.add('error');
133
216
  status.textContent = error.message;
134
217
  try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
@@ -163,7 +246,7 @@ export function createCocoa(options = {}) {
163
246
  }
164
247
  function expire(error) {
165
248
  if (error.status === 401 || error.status === 404) {
166
- terminal = true;
249
+ terminal = true; expired = true;
167
250
  session = null;
168
251
  pending = null;
169
252
  persist();
@@ -190,12 +273,15 @@ export function createCocoa(options = {}) {
190
273
  if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
191
274
  version = data.version;
192
275
  }
276
+ incident = data.escalation || incident;
193
277
  terminal = data.state === 'closed';
194
278
  paused = !data.enabled;
195
279
  status.classList.remove('error');
196
- status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
280
+ status.textContent = incident ? 'Your incident is with the support team. Follow-up will be by email.' : terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
197
281
  data.state === 'human' ? 'A person is handling your conversation.' :
198
282
  data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
283
+ const last = data.messages.filter(m=>['customer','agent','human'].includes(m.actor)).at(-1);
284
+ thinking.hidden = !(last?.actor === 'customer' && data.agentOnline && data.state === 'agent' && !terminal && !paused && !incident && handoff.hidden);
199
285
  controls();
200
286
  }
201
287
  function schedule() {
@@ -205,7 +291,7 @@ export function createCocoa(options = {}) {
205
291
  }
206
292
  }
207
293
  async function refresh() {
208
- if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
294
+ if (destroyed || refreshing || sending || submittingIncident || panel.hidden || document.hidden || !session || terminal) return;
209
295
  refreshing = true;
210
296
  const current = generation;
211
297
  try {
@@ -228,18 +314,25 @@ export function createCocoa(options = {}) {
228
314
  }
229
315
  function open() {
230
316
  if (destroyed) return;
317
+ dismissGreeting();
231
318
  panel.hidden = false;
232
319
  launcher.setAttribute('aria-expanded', 'true');
233
- (terminal ? fresh : input).focus();
320
+ (!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
234
321
  void refresh();
235
322
  }
236
323
  function reset() {
237
324
  if (destroyed) return;
238
325
  generation++;
239
326
  sending = refreshing = false;
327
+ thinking.hidden = true;
240
328
  for (const request of requests) request.abort();
241
329
  clearTimeout(timer);
242
- session = pending = null;
330
+ session = pending = incident = incidentPending = null;
331
+ expired = submittingIncident = false;
332
+ handoff.hidden = true; composer.hidden = messages.hidden = false;
333
+ submitIncident.disabled = back.disabled = false;
334
+ for(const field of Object.values(fields)) { field.value = ''; field.disabled = false; }
335
+ handoffError.textContent = '';
243
336
  terminal = paused = false;
244
337
  version = -1;
245
338
  failures = 0;
@@ -254,12 +347,13 @@ export function createCocoa(options = {}) {
254
347
  }
255
348
  const visibility = () => {
256
349
  clearTimeout(timer);
257
- if (!document.hidden) void refresh();
350
+ if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
258
351
  };
259
352
  document.addEventListener('visibilitychange', visibility);
260
353
  const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
261
354
  if (destroyed) return;
262
355
  destroyed = true;
356
+ dismissGreeting(false);
263
357
  generation++;
264
358
  clearTimeout(timer);
265
359
  for (const request of requests) request.abort();
@@ -268,6 +362,8 @@ export function createCocoa(options = {}) {
268
362
  instances.delete(key);
269
363
  } };
270
364
  launcher.onclick = widget.toggle;
365
+ greetingOpen.onclick = open;
366
+ greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
271
367
  panel.querySelector('header button').onclick = close;
272
368
  fresh.onclick = reset;
273
369
  root.addEventListener('keydown', (event) => {
@@ -276,8 +372,9 @@ export function createCocoa(options = {}) {
276
372
  form.onsubmit = async (event) => {
277
373
  event.preventDefault();
278
374
  const body = input.value.trim();
279
- if (!body || sending || terminal || paused || destroyed) return;
375
+ if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
280
376
  sending = true;
377
+ thinking.hidden = true;
281
378
  controls();
282
379
  clearTimeout(timer);
283
380
  const current = generation;
@@ -312,6 +409,60 @@ export function createCocoa(options = {}) {
312
409
  }
313
410
  }
314
411
  };
412
+ function showChat() {
413
+ handoff.hidden = true; composer.hidden = messages.hidden = false; controls();
414
+ if (!panel.hidden) (input.disabled ? help : input).focus();
415
+ }
416
+ help.onclick = () => {
417
+ if (expired || paused || incident || sending || submittingIncident) return;
418
+ const last = [...messages.querySelectorAll('.customer span')].at(-1)?.textContent || input.value.trim();
419
+ if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
420
+ if (!fields.details.value) fields.details.value = last || '';
421
+ if (!fields.name.value) fields.name.value = name.value;
422
+ thinking.hidden = true;
423
+ handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
424
+ };
425
+ back.onclick = showChat;
426
+ handoff.onsubmit = async event => {
427
+ event.preventDefault();
428
+ if (submittingIncident || sending || expired || paused || incident || destroyed) return;
429
+ const data = Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,v.value.trim()]));
430
+ if (!data.summary || !data.details || !data.email) return;
431
+ submittingIncident = true; submitIncident.disabled = back.disabled = true;
432
+ for(const field of Object.values(fields)) field.disabled = true;
433
+ handoffError.textContent = ''; controls();
434
+ const current = generation;
435
+ try {
436
+ if (!session) {
437
+ const result = await call(`/chat/${options.chatbotId}/sessions`, { label: data.name || 'Visitor' });
438
+ if (destroyed || current !== generation) return;
439
+ if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
440
+ session = {id:result.id,token:result.token};
441
+ }
442
+ if (!incidentPending || Object.keys(data).some(k=>data[k]!==incidentPending[k])) incidentPending = {...data,idempotencyKey:crypto.randomUUID()};
443
+ persist();
444
+ const result = await call(`/chat/sessions/${session.id}/escalations`, incidentPending, session.token);
445
+ if (destroyed || current !== generation) return;
446
+ if (!result?.reference || !result?.email) throw new Error('Submission could not be confirmed. Please retry.');
447
+ incident = result; incidentPending = null; persist(); showChat(); handoffResult.focus();
448
+ } catch(error) {
449
+ if (!destroyed && current === generation) {
450
+ expire(error);
451
+ handoffError.textContent = expired ? 'This session expired. Return to chat and explicitly start a new conversation before submitting.' : 'Submission could not be confirmed. Your details are kept here; retry to check or submit the same incident.';
452
+ }
453
+ } finally {
454
+ if (!destroyed && current === generation) {
455
+ submittingIncident = false; submitIncident.disabled = back.disabled = false;
456
+ for(const field of Object.values(fields)) field.disabled = false;
457
+ controls(); schedule();
458
+ }
459
+ }
460
+ };
461
+ if (!greetingSeen && !session && options.greetingMessage !== false) {
462
+ document.addEventListener('pointerdown', unlockAudio);
463
+ document.addEventListener('keydown', unlockAudio);
464
+ greetingTimer=setTimeout(showGreeting,greetingDelay);
465
+ }
315
466
  instances.set(key, widget);
316
467
  return widget;
317
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sub-zero/cocoa",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Drop-in Cocoa support chat for any website, powered by SubZero.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",