@sub-zero/cocoa 0.2.0 → 0.2.2

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
@@ -169,3 +169,11 @@ may be private.
169
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
170
 
171
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,6 +35,9 @@ 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:relative;margin:0 0 12px auto;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}
@@ -84,6 +89,12 @@ function createCocoa(options = {}) {
84
89
  handoff.append(handoffError, handoffActions);
85
90
  const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
86
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);
87
98
  panel.querySelector('strong').textContent = options.title || 'Cocoa support';
88
99
  const launcher = document.createElement('button');
89
100
  launcher.type = 'button';
@@ -98,7 +109,11 @@ function createCocoa(options = {}) {
98
109
  launcher.setAttribute('aria-expanded', 'false');
99
110
  launcher.setAttribute('aria-controls', panel.id);
100
111
  launcher.setAttribute('aria-haspopup', 'dialog');
101
- 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);
102
117
  document.body.append(host);
103
118
  const messages = panel.querySelector('.messages');
104
119
  const status = panel.querySelector('.status');
@@ -111,6 +126,41 @@ function createCocoa(options = {}) {
111
126
  let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
112
127
  let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
113
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
+ }
114
164
  const rendered = new Set();
115
165
  const requests = new Set();
116
166
  const persist = () => {
@@ -146,6 +196,7 @@ function createCocoa(options = {}) {
146
196
  welcome();
147
197
  const controls = () => {
148
198
  const focusWasInComposer = root.activeElement === input || root.activeElement === send;
199
+ if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
149
200
  help.disabled = sending || submittingIncident || expired || paused;
150
201
  help.hidden = !!incident || !handoff.hidden;
151
202
  handoffResult.hidden = !incident;
@@ -162,6 +213,7 @@ function createCocoa(options = {}) {
162
213
  };
163
214
  controls();
164
215
  const report = (error) => {
216
+ thinking.hidden = true;
165
217
  status.classList.add('error');
166
218
  status.textContent = error.message;
167
219
  try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
@@ -230,6 +282,8 @@ function createCocoa(options = {}) {
230
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.' :
231
283
  data.state === 'human' ? 'A person is handling your conversation.' :
232
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);
233
287
  controls();
234
288
  }
235
289
  function schedule() {
@@ -262,6 +316,7 @@ function createCocoa(options = {}) {
262
316
  }
263
317
  function open() {
264
318
  if (destroyed) return;
319
+ dismissGreeting();
265
320
  panel.hidden = false;
266
321
  launcher.setAttribute('aria-expanded', 'true');
267
322
  (!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
@@ -271,6 +326,7 @@ function createCocoa(options = {}) {
271
326
  if (destroyed) return;
272
327
  generation++;
273
328
  sending = refreshing = false;
329
+ thinking.hidden = true;
274
330
  for (const request of requests) request.abort();
275
331
  clearTimeout(timer);
276
332
  session = pending = incident = incidentPending = null;
@@ -293,12 +349,13 @@ function createCocoa(options = {}) {
293
349
  }
294
350
  const visibility = () => {
295
351
  clearTimeout(timer);
296
- if (!document.hidden) void refresh();
352
+ if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
297
353
  };
298
354
  document.addEventListener('visibilitychange', visibility);
299
355
  const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
300
356
  if (destroyed) return;
301
357
  destroyed = true;
358
+ dismissGreeting(false);
302
359
  generation++;
303
360
  clearTimeout(timer);
304
361
  for (const request of requests) request.abort();
@@ -307,6 +364,8 @@ function createCocoa(options = {}) {
307
364
  instances.delete(key);
308
365
  } };
309
366
  launcher.onclick = widget.toggle;
367
+ greetingOpen.onclick = open;
368
+ greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
310
369
  panel.querySelector('header button').onclick = close;
311
370
  fresh.onclick = reset;
312
371
  root.addEventListener('keydown', (event) => {
@@ -317,6 +376,7 @@ function createCocoa(options = {}) {
317
376
  const body = input.value.trim();
318
377
  if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
319
378
  sending = true;
379
+ thinking.hidden = true;
320
380
  controls();
321
381
  clearTimeout(timer);
322
382
  const current = generation;
@@ -361,6 +421,7 @@ function createCocoa(options = {}) {
361
421
  if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
362
422
  if (!fields.details.value) fields.details.value = last || '';
363
423
  if (!fields.name.value) fields.name.value = name.value;
424
+ thinking.hidden = true;
364
425
  handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
365
426
  };
366
427
  back.onclick = showChat;
@@ -399,6 +460,11 @@ function createCocoa(options = {}) {
399
460
  }
400
461
  }
401
462
  };
463
+ if (!greetingSeen && !session && options.greetingMessage !== false) {
464
+ document.addEventListener('pointerdown', unlockAudio);
465
+ document.addEventListener('keydown', unlockAudio);
466
+ greetingTimer=setTimeout(showGreeting,greetingDelay);
467
+ }
402
468
  instances.set(key, widget);
403
469
  return widget;
404
470
  }
@@ -413,6 +479,9 @@ if (script?.dataset.chatbot) {
413
479
  launcherLabel: script.dataset.launcherLabel,
414
480
  accentColor: script.dataset.accentColor,
415
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',
416
485
  };
417
486
  const mount = () => { window.Cocoa.widget = createCocoa(options); };
418
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,6 +33,9 @@ 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:relative;margin:0 0 12px auto;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}
@@ -82,6 +87,12 @@ function createCocoa(options = {}) {
82
87
  handoff.append(handoffError, handoffActions);
83
88
  const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
84
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);
85
96
  panel.querySelector('strong').textContent = options.title || 'Cocoa support';
86
97
  const launcher = document.createElement('button');
87
98
  launcher.type = 'button';
@@ -96,7 +107,11 @@ function createCocoa(options = {}) {
96
107
  launcher.setAttribute('aria-expanded', 'false');
97
108
  launcher.setAttribute('aria-controls', panel.id);
98
109
  launcher.setAttribute('aria-haspopup', 'dialog');
99
- 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);
100
115
  document.body.append(host);
101
116
  const messages = panel.querySelector('.messages');
102
117
  const status = panel.querySelector('.status');
@@ -109,6 +124,41 @@ function createCocoa(options = {}) {
109
124
  let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
110
125
  let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
111
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
+ }
112
162
  const rendered = new Set();
113
163
  const requests = new Set();
114
164
  const persist = () => {
@@ -144,6 +194,7 @@ function createCocoa(options = {}) {
144
194
  welcome();
145
195
  const controls = () => {
146
196
  const focusWasInComposer = root.activeElement === input || root.activeElement === send;
197
+ if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
147
198
  help.disabled = sending || submittingIncident || expired || paused;
148
199
  help.hidden = !!incident || !handoff.hidden;
149
200
  handoffResult.hidden = !incident;
@@ -160,6 +211,7 @@ function createCocoa(options = {}) {
160
211
  };
161
212
  controls();
162
213
  const report = (error) => {
214
+ thinking.hidden = true;
163
215
  status.classList.add('error');
164
216
  status.textContent = error.message;
165
217
  try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
@@ -228,6 +280,8 @@ function createCocoa(options = {}) {
228
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.' :
229
281
  data.state === 'human' ? 'A person is handling your conversation.' :
230
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);
231
285
  controls();
232
286
  }
233
287
  function schedule() {
@@ -260,6 +314,7 @@ function createCocoa(options = {}) {
260
314
  }
261
315
  function open() {
262
316
  if (destroyed) return;
317
+ dismissGreeting();
263
318
  panel.hidden = false;
264
319
  launcher.setAttribute('aria-expanded', 'true');
265
320
  (!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
@@ -269,6 +324,7 @@ function createCocoa(options = {}) {
269
324
  if (destroyed) return;
270
325
  generation++;
271
326
  sending = refreshing = false;
327
+ thinking.hidden = true;
272
328
  for (const request of requests) request.abort();
273
329
  clearTimeout(timer);
274
330
  session = pending = incident = incidentPending = null;
@@ -291,12 +347,13 @@ function createCocoa(options = {}) {
291
347
  }
292
348
  const visibility = () => {
293
349
  clearTimeout(timer);
294
- if (!document.hidden) void refresh();
350
+ if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
295
351
  };
296
352
  document.addEventListener('visibilitychange', visibility);
297
353
  const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
298
354
  if (destroyed) return;
299
355
  destroyed = true;
356
+ dismissGreeting(false);
300
357
  generation++;
301
358
  clearTimeout(timer);
302
359
  for (const request of requests) request.abort();
@@ -305,6 +362,8 @@ function createCocoa(options = {}) {
305
362
  instances.delete(key);
306
363
  } };
307
364
  launcher.onclick = widget.toggle;
365
+ greetingOpen.onclick = open;
366
+ greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
308
367
  panel.querySelector('header button').onclick = close;
309
368
  fresh.onclick = reset;
310
369
  root.addEventListener('keydown', (event) => {
@@ -315,6 +374,7 @@ function createCocoa(options = {}) {
315
374
  const body = input.value.trim();
316
375
  if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
317
376
  sending = true;
377
+ thinking.hidden = true;
318
378
  controls();
319
379
  clearTimeout(timer);
320
380
  const current = generation;
@@ -359,6 +419,7 @@ function createCocoa(options = {}) {
359
419
  if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
360
420
  if (!fields.details.value) fields.details.value = last || '';
361
421
  if (!fields.name.value) fields.name.value = name.value;
422
+ thinking.hidden = true;
362
423
  handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
363
424
  };
364
425
  back.onclick = showChat;
@@ -397,6 +458,11 @@ function createCocoa(options = {}) {
397
458
  }
398
459
  }
399
460
  };
461
+ if (!greetingSeen && !session && options.greetingMessage !== false) {
462
+ document.addEventListener('pointerdown', unlockAudio);
463
+ document.addEventListener('keydown', unlockAudio);
464
+ greetingTimer=setTimeout(showGreeting,greetingDelay);
465
+ }
400
466
  instances.set(key, widget);
401
467
  return widget;
402
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,6 +33,9 @@ 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:relative;margin:0 0 12px auto;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}
@@ -82,6 +87,12 @@ export function createCocoa(options = {}) {
82
87
  handoff.append(handoffError, handoffActions);
83
88
  const handoffResult = element('p', {class:'handoff-result',role:'status',tabindex:'-1',hidden:''});
84
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);
85
96
  panel.querySelector('strong').textContent = options.title || 'Cocoa support';
86
97
  const launcher = document.createElement('button');
87
98
  launcher.type = 'button';
@@ -96,7 +107,11 @@ export function createCocoa(options = {}) {
96
107
  launcher.setAttribute('aria-expanded', 'false');
97
108
  launcher.setAttribute('aria-controls', panel.id);
98
109
  launcher.setAttribute('aria-haspopup', 'dialog');
99
- 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);
100
115
  document.body.append(host);
101
116
  const messages = panel.querySelector('.messages');
102
117
  const status = panel.querySelector('.status');
@@ -109,6 +124,41 @@ export function createCocoa(options = {}) {
109
124
  let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
110
125
  let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
111
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
+ }
112
162
  const rendered = new Set();
113
163
  const requests = new Set();
114
164
  const persist = () => {
@@ -144,6 +194,7 @@ export function createCocoa(options = {}) {
144
194
  welcome();
145
195
  const controls = () => {
146
196
  const focusWasInComposer = root.activeElement === input || root.activeElement === send;
197
+ if (incident || !handoff.hidden || terminal || paused) thinking.hidden = true;
147
198
  help.disabled = sending || submittingIncident || expired || paused;
148
199
  help.hidden = !!incident || !handoff.hidden;
149
200
  handoffResult.hidden = !incident;
@@ -160,6 +211,7 @@ export function createCocoa(options = {}) {
160
211
  };
161
212
  controls();
162
213
  const report = (error) => {
214
+ thinking.hidden = true;
163
215
  status.classList.add('error');
164
216
  status.textContent = error.message;
165
217
  try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
@@ -228,6 +280,8 @@ export function createCocoa(options = {}) {
228
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.' :
229
281
  data.state === 'human' ? 'A person is handling your conversation.' :
230
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);
231
285
  controls();
232
286
  }
233
287
  function schedule() {
@@ -260,6 +314,7 @@ export function createCocoa(options = {}) {
260
314
  }
261
315
  function open() {
262
316
  if (destroyed) return;
317
+ dismissGreeting();
263
318
  panel.hidden = false;
264
319
  launcher.setAttribute('aria-expanded', 'true');
265
320
  (!handoff.hidden ? fields.email : terminal ? fresh : input).focus();
@@ -269,6 +324,7 @@ export function createCocoa(options = {}) {
269
324
  if (destroyed) return;
270
325
  generation++;
271
326
  sending = refreshing = false;
327
+ thinking.hidden = true;
272
328
  for (const request of requests) request.abort();
273
329
  clearTimeout(timer);
274
330
  session = pending = incident = incidentPending = null;
@@ -291,12 +347,13 @@ export function createCocoa(options = {}) {
291
347
  }
292
348
  const visibility = () => {
293
349
  clearTimeout(timer);
294
- if (!document.hidden) void refresh();
350
+ if (!document.hidden) { void refresh(); if (!greetingSeen) { clearTimeout(greetingTimer); greetingTimer=setTimeout(showGreeting,greetingDelay); } }
295
351
  };
296
352
  document.addEventListener('visibilitychange', visibility);
297
353
  const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
298
354
  if (destroyed) return;
299
355
  destroyed = true;
356
+ dismissGreeting(false);
300
357
  generation++;
301
358
  clearTimeout(timer);
302
359
  for (const request of requests) request.abort();
@@ -305,6 +362,8 @@ export function createCocoa(options = {}) {
305
362
  instances.delete(key);
306
363
  } };
307
364
  launcher.onclick = widget.toggle;
365
+ greetingOpen.onclick = open;
366
+ greetingDismiss.onclick = () => { dismissGreeting(); launcher.focus(); };
308
367
  panel.querySelector('header button').onclick = close;
309
368
  fresh.onclick = reset;
310
369
  root.addEventListener('keydown', (event) => {
@@ -315,6 +374,7 @@ export function createCocoa(options = {}) {
315
374
  const body = input.value.trim();
316
375
  if (!body || sending || submittingIncident || terminal || paused || destroyed) return;
317
376
  sending = true;
377
+ thinking.hidden = true;
318
378
  controls();
319
379
  clearTimeout(timer);
320
380
  const current = generation;
@@ -359,6 +419,7 @@ export function createCocoa(options = {}) {
359
419
  if (!fields.summary.value) fields.summary.value = (last || 'Help with ' + (options.title || 'this application')).slice(0,200);
360
420
  if (!fields.details.value) fields.details.value = last || '';
361
421
  if (!fields.name.value) fields.name.value = name.value;
422
+ thinking.hidden = true;
362
423
  handoff.hidden = false; composer.hidden = messages.hidden = true; controls(); fields.email.focus();
363
424
  };
364
425
  back.onclick = showChat;
@@ -397,6 +458,11 @@ export function createCocoa(options = {}) {
397
458
  }
398
459
  }
399
460
  };
461
+ if (!greetingSeen && !session && options.greetingMessage !== false) {
462
+ document.addEventListener('pointerdown', unlockAudio);
463
+ document.addEventListener('keydown', unlockAudio);
464
+ greetingTimer=setTimeout(showGreeting,greetingDelay);
465
+ }
400
466
  instances.set(key, widget);
401
467
  return widget;
402
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sub-zero/cocoa",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Drop-in Cocoa support chat for any website, powered by SubZero.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",