@oyehello/chat 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ var marked = require('marked');
4
+ var DOMPurify = require('dompurify');
5
+
6
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
+
8
+ var DOMPurify__default = /*#__PURE__*/_interopDefault(DOMPurify);
9
+
3
10
  // src/errors.ts
4
11
  var ChatApiError = class extends Error {
5
12
  constructor(message, status, code, meta) {
@@ -159,7 +166,7 @@ function createChatClient(options) {
159
166
  seenTurnIds.add(ev.turnId);
160
167
  const m = streamingId ? messages.find((x) => x.id === streamingId) : void 0;
161
168
  if (m) {
162
- m.text = ev.text;
169
+ m.text = ev.text || m.text;
163
170
  m.streaming = false;
164
171
  m.turnId = ev.turnId;
165
172
  } else {
@@ -281,7 +288,10 @@ function createChatClient(options) {
281
288
  return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
282
289
  },
283
290
  async start() {
284
- if (state !== "idle") return;
291
+ if (state === "opening" || state === "ready") return;
292
+ closed = false;
293
+ clearReconnect();
294
+ reconnectAttempts = 0;
285
295
  setState("opening");
286
296
  try {
287
297
  await open();
@@ -343,42 +353,465 @@ function createChatClient(options) {
343
353
  }
344
354
  };
345
355
  }
356
+ marked.marked.setOptions({ gfm: true, breaks: true });
357
+ var hookInstalled = false;
358
+ function installLinkHook() {
359
+ if (hookInstalled) return;
360
+ DOMPurify__default.default.addHook("afterSanitizeAttributes", (node) => {
361
+ if (node.nodeName === "A") {
362
+ node.setAttribute("target", "_blank");
363
+ node.setAttribute("rel", "noopener noreferrer nofollow");
364
+ }
365
+ });
366
+ hookInstalled = true;
367
+ }
368
+ var ALLOWED_TAGS = [
369
+ "p",
370
+ "br",
371
+ "strong",
372
+ "em",
373
+ "b",
374
+ "i",
375
+ "del",
376
+ "s",
377
+ "code",
378
+ "pre",
379
+ "blockquote",
380
+ "ul",
381
+ "ol",
382
+ "li",
383
+ "a",
384
+ "h1",
385
+ "h2",
386
+ "h3",
387
+ "h4",
388
+ "h5",
389
+ "h6",
390
+ "hr",
391
+ "table",
392
+ "thead",
393
+ "tbody",
394
+ "tr",
395
+ "th",
396
+ "td",
397
+ "span"
398
+ ];
399
+ var ALLOWED_ATTR = ["href", "target", "rel", "class"];
400
+ function renderMarkdown(text) {
401
+ installLinkHook();
402
+ const raw = marked.marked.parse(text, { async: false });
403
+ return DOMPurify__default.default.sanitize(raw, {
404
+ ALLOWED_TAGS,
405
+ ALLOWED_ATTR,
406
+ ALLOW_DATA_ATTR: false,
407
+ ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i
408
+ });
409
+ }
410
+
411
+ // src/styles.ts
412
+ var STYLES = `
413
+ :host {
414
+ /* accent (consumer-overridable via --oc-accent) + its derivations */
415
+ --oc-ac: var(--oc-accent, #096DD9);
416
+ --oc-btn-rim: color-mix(in oklab, var(--oc-ac) 70%, black);
417
+ --oc-btn-edge: 0 6px 16px -10px color-mix(in oklab, var(--oc-ac) 55%, transparent);
418
+ --oc-danger: #D92D20;
419
+ --oc-ease: cubic-bezier(0.16,1,0.3,1);
420
+ --oc-bounce: cubic-bezier(0.18,0.89,0.32,1.13);
421
+ --oc-mono: 'Geist Mono', ui-monospace, monospace;
422
+ --oc-r-full: 999px;
423
+
424
+ /* light palette (base) */
425
+ --oc-bg: #FFFFFF; --oc-bg-sunk: #F6F8FB;
426
+ --oc-fg: #0A0D12; --oc-fg-muted: rgba(10,13,18,0.66); --oc-fg-dim: rgba(10,13,18,0.44);
427
+ --oc-border: rgba(9,13,20,0.10);
428
+ --oc-accent-text: color-mix(in oklab, var(--oc-ac) 88%, black);
429
+ --oc-tint-1: rgba(9,13,20,0.025); --oc-tint-2: rgba(9,13,20,0.04); --oc-tint-3: rgba(9,13,20,0.055);
430
+ --oc-field-bg: #FFFFFF; --oc-field-border: rgba(9,13,20,0.22);
431
+ --oc-glass-border: rgba(9,13,20,0.12);
432
+
433
+ position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
434
+ font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
435
+ -webkit-font-smoothing: antialiased;
436
+ }
437
+ @media (prefers-color-scheme: dark) {
438
+ :host(:not([theme="light"])) {
439
+ --oc-bg: #000000; --oc-bg-sunk: #000000;
440
+ --oc-fg: #FFFFFF; --oc-fg-muted: rgba(255,255,255,0.68); --oc-fg-dim: rgba(255,255,255,0.42);
441
+ --oc-border: rgba(255,255,255,0.09);
442
+ --oc-accent-text: color-mix(in oklab, var(--oc-ac) 62%, white);
443
+ --oc-tint-1: rgba(255,255,255,0.03); --oc-tint-2: rgba(255,255,255,0.05); --oc-tint-3: rgba(255,255,255,0.07);
444
+ --oc-field-bg: rgba(255,255,255,0.045); --oc-field-border: rgba(255,255,255,0.20);
445
+ --oc-glass-border: rgba(255,255,255,0.12);
446
+ }
447
+ }
448
+ :host([theme="dark"]) {
449
+ --oc-bg: #000000; --oc-bg-sunk: #000000;
450
+ --oc-fg: #FFFFFF; --oc-fg-muted: rgba(255,255,255,0.68); --oc-fg-dim: rgba(255,255,255,0.42);
451
+ --oc-border: rgba(255,255,255,0.09);
452
+ --oc-accent-text: color-mix(in oklab, var(--oc-ac) 62%, white);
453
+ --oc-tint-1: rgba(255,255,255,0.03); --oc-tint-2: rgba(255,255,255,0.05); --oc-tint-3: rgba(255,255,255,0.07);
454
+ --oc-field-bg: rgba(255,255,255,0.045); --oc-field-border: rgba(255,255,255,0.20);
455
+ --oc-glass-border: rgba(255,255,255,0.12);
456
+ }
457
+ * { box-sizing: border-box; }
458
+ button { font-family: inherit; }
459
+
460
+ .oc { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
461
+
462
+ /* \u2500\u2500 Launcher \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
463
+ .launcher {
464
+ position: relative; flex: none; width: 46px; height: 46px; border-radius: var(--oc-r-full);
465
+ border: 1px solid var(--oc-btn-rim); background: var(--oc-ac);
466
+ box-shadow: 0 8px 22px -8px color-mix(in oklab, var(--oc-ac) 70%, transparent), 0 18px 48px -18px rgba(0,0,0,0.6);
467
+ color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer;
468
+ transition: transform .26s var(--oc-bounce), filter .2s var(--oc-ease); align-self: flex-end;
469
+ }
470
+ .launcher:hover { transform: scale(1.06); }
471
+ .launcher:active { transform: scale(0.93); }
472
+ .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
473
+ .oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
474
+ .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
475
+ .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
476
+ .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
477
+ .oc.open .ic-chat { opacity: 0; transform: rotate(-90deg) scale(0.6); }
478
+ .oc.open .ic-close { opacity: 1; transform: rotate(0) scale(1); }
479
+ .badge {
480
+ position: absolute; top: -2px; right: -2px; min-width: 17px; height: 17px; padding: 0 5px;
481
+ border-radius: var(--oc-r-full); border: 2px solid var(--oc-bg-sunk); background: var(--oc-danger);
482
+ color: #fff; font-family: var(--oc-mono); font-size: 10px; font-weight: 700;
483
+ display: flex; align-items: center; justify-content: center; opacity: 0; transform: scale(0.4);
484
+ transition: all .26s var(--oc-bounce);
485
+ }
486
+ .oc.unread:not(.open) .badge { opacity: 1; transform: scale(1); }
487
+
488
+ /* \u2500\u2500 Teaser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
489
+ .teaser {
490
+ position: absolute; right: 0; bottom: 60px; display: none; align-items: center; gap: 11px;
491
+ opacity: 0; transform: translateY(8px) scale(0.94); pointer-events: none;
492
+ transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
493
+ }
494
+ .oc.has-teaser .teaser { display: flex; }
495
+ .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
496
+ .teaser-btn {
497
+ max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
498
+ border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
499
+ box-shadow: 0 10px 30px -14px rgba(0,0,0,0.5); color: var(--oc-fg); font-size: 13.5px; line-height: 1.45;
500
+ text-align: left; cursor: pointer; transition: transform .2s var(--oc-ease);
501
+ }
502
+ .teaser-btn:hover { transform: translateY(-1px); }
503
+ .teaser-btn .logo { flex: none; width: 24px; height: 24px; border-radius: 6px; overflow: hidden; color: var(--oc-ac); }
504
+ .teaser-x {
505
+ flex: none; width: 22px; height: 22px; border-radius: var(--oc-r-full); border: 1px solid var(--oc-border);
506
+ background: var(--oc-bg); color: var(--oc-fg-dim); display: flex; align-items: center; justify-content: center; cursor: pointer;
507
+ }
508
+ .teaser-x:hover { color: var(--oc-fg); }
509
+
510
+ /* \u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
511
+ .panel {
512
+ position: relative; width: min(384px, calc(100vw - 40px)); height: min(560px, calc(100vh - 108px));
513
+ display: flex; flex-direction: column; border-radius: 20px; border: 1px solid var(--oc-glass-border);
514
+ background: var(--oc-bg); box-shadow: 0 12px 28px -12px rgba(0,0,0,0.28), 0 40px 90px -30px rgba(0,0,0,0.55);
515
+ overflow: hidden; transform-origin: bottom right;
516
+ opacity: 0; transform: translateY(16px) scale(0.94); pointer-events: none; visibility: hidden;
517
+ transition: opacity .24s var(--oc-ease), transform .34s var(--oc-bounce), visibility .34s, width .3s var(--oc-ease), height .3s var(--oc-ease);
518
+ }
519
+ .oc.open .panel { opacity: 1; transform: none; pointer-events: auto; visibility: visible; }
520
+ .oc.max .panel { width: min(720px, calc(100vw - 40px)); height: calc(100vh - 108px); }
521
+
522
+ .hd { position: relative; flex: none; display: flex; align-items: center; gap: 12px; padding: 13px 14px 12px; background: var(--oc-ac); color: #fff; overflow: hidden; }
523
+ .hd-grid { pointer-events: none; position: absolute; inset: 0; background-image: linear-gradient(90deg, rgba(255,255,255,0.11) 1px, transparent 1px), linear-gradient(180deg, rgba(255,255,255,0.11) 1px, transparent 1px); background-size: 26px 26px; }
524
+ .hd .logo { position: relative; flex: none; width: 32px; height: 32px; border-radius: 7px; overflow: hidden; color: rgba(255,255,255,0.22); }
525
+ .hd-txt { position: relative; flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
526
+ .hd-title { font-size: 14.5px; font-weight: 600; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
527
+ .hd-status { display: flex; align-items: center; gap: 6px; font-size: 11.5px; opacity: 0.9; }
528
+ .hd-status .dot { width: 6px; height: 6px; border-radius: var(--oc-r-full); background: #7BE3A6; flex: none; }
529
+ .hd-status .t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
530
+ .hd-btn { position: relative; flex: none; width: 28px; height: 28px; border-radius: 8px; border: none; background: rgba(255,255,255,0.16); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background .18s var(--oc-ease); }
531
+ .hd-btn:hover { background: rgba(255,255,255,0.28); }
532
+ .hd-btn .i { position: absolute; display: flex; transition: opacity .2s var(--oc-ease); }
533
+ .i-expand { opacity: 1; } .i-shrink { opacity: 0; }
534
+ .oc.max .i-expand { opacity: 0; } .oc.max .i-shrink { opacity: 1; }
535
+
536
+ .err { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: #fff; background: var(--oc-danger); }
537
+ .err .retry { padding: 3px 10px; font-size: 11px; background: rgba(255,255,255,0.2); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
538
+
539
+ /* \u2500\u2500 Message list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
540
+ .body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 16px 16px 26px; background: var(--oc-bg); scroll-behavior: smooth; }
541
+ .turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
542
+ .turn.bot { align-items: flex-start; } .turn.user { align-items: flex-end; }
543
+ .b { font-size: 13.5px; line-height: 1.55; text-wrap: pretty; word-break: break-word; }
544
+ .turn.bot .b { max-width: 86%; padding: 13px 15px; border-radius: 20px; background: var(--oc-tint-2); color: var(--oc-fg); }
545
+ .turn.user .b { max-width: 82%; padding: 11px 15px; border-radius: var(--oc-r-full); background: var(--oc-ac); color: #fff; }
546
+ .turn.human .b { max-width: 86%; padding: 13px 15px; border-radius: 20px; background: color-mix(in oklab, var(--oc-ac) 16%, transparent); color: var(--oc-fg); }
547
+ .src { display: inline-flex; align-items: center; gap: 7px; padding: 5px 10px 5px 8px; border-radius: var(--oc-r-full); border: 1px solid var(--oc-border); background: var(--oc-tint-1); max-width: 100%; }
548
+ .src .t { min-width: 0; font-size: 11.5px; color: var(--oc-fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
549
+ .byline { display: flex; align-items: center; gap: 6px; padding: 0 4px; font-size: 11.5px; color: var(--oc-fg-dim); }
550
+ .human-label { font-size: 11px; font-weight: 600; opacity: .7; margin-bottom: 2px; }
551
+
552
+ /* markdown inside a bot bubble */
553
+ .md > :first-child { margin-top: 0; } .md > :last-child { margin-bottom: 0; }
554
+ .md p { margin: 0 0 8px; } .md ul, .md ol { margin: 0 0 8px; padding-left: 20px; } .md li { margin: 2px 0; }
555
+ .md a { color: var(--oc-accent-text); text-decoration: underline; text-underline-offset: 2px; }
556
+ .md code { font-family: var(--oc-mono); font-size: 12.5px; background: var(--oc-tint-3); padding: 1px 5px; border-radius: 5px; }
557
+ .md pre { background: color-mix(in oklab, var(--oc-fg) 10%, transparent); padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 8px; }
558
+ .md pre code { background: none; padding: 0; }
559
+ .md blockquote { margin: 0 0 8px; padding-left: 10px; border-left: 3px solid var(--oc-border); color: var(--oc-fg-muted); }
560
+ .md h1, .md h2, .md h3, .md h4 { margin: 8px 0 6px; font-size: 15px; }
561
+ .md table { border-collapse: collapse; font-size: 13px; margin: 0 0 8px; display: block; overflow-x: auto; }
562
+ .md th, .md td { border: 1px solid var(--oc-border); padding: 4px 8px; }
563
+ .att-thumb { display: block; max-width: 180px; max-height: 180px; border-radius: 8px; margin-bottom: 6px; }
564
+ .att-chip { display: inline-block; font-size: 12px; background: var(--oc-tint-3); border-radius: 6px; padding: 2px 8px; margin: 0 0 6px; }
565
+ .turn.user .att-chip { background: rgba(255,255,255,0.25); }
566
+
567
+ .typing { align-self: flex-start; display: flex; align-items: center; gap: 5px; padding: 13px 16px; border-radius: 20px; background: var(--oc-tint-2); animation: w-in .3s var(--oc-ease) both; }
568
+ .typing span { width: 6px; height: 6px; border-radius: var(--oc-r-full); background: var(--oc-fg-muted); animation: w-dot 1.2s ease-in-out infinite; }
569
+ .typing span:nth-child(2) { animation-delay: .15s; } .typing span:nth-child(3) { animation-delay: .3s; }
570
+
571
+ .chips { display: flex; flex-wrap: wrap; gap: 7px; padding-top: 2px; animation: w-in .42s var(--oc-ease) both; animation-delay: .3s; }
572
+ .chip { padding: 8px 13px; border-radius: var(--oc-r-full); border: 1px solid color-mix(in oklab, var(--oc-ac) 34%, transparent); background: color-mix(in oklab, var(--oc-ac) 10%, transparent); color: var(--oc-accent-text); font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; transition: all .18s var(--oc-ease); }
573
+ .chip:hover { background: color-mix(in oklab, var(--oc-ac) 18%, transparent); }
574
+
575
+ /* \u2500\u2500 Jump to latest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
576
+ .jump-wrap { position: relative; flex: none; height: 0; z-index: 2; display: flex; justify-content: center; }
577
+ .jump { position: absolute; bottom: 8px; width: 34px; height: 34px; border-radius: var(--oc-r-full); border: 1px solid var(--oc-border); background: var(--oc-bg); box-shadow: 0 8px 20px -10px rgba(0,0,0,0.5); color: var(--oc-fg-muted); display: flex; align-items: center; justify-content: center; cursor: pointer; opacity: 0; transform: translateY(6px) scale(0.8); pointer-events: none; transition: all .22s var(--oc-ease); }
578
+ .oc.away .jump { opacity: 1; transform: none; pointer-events: auto; }
579
+ .jump:hover { color: var(--oc-fg); border-color: var(--oc-fg-dim); }
580
+
581
+ /* \u2500\u2500 Composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
582
+ .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
583
+ .field { display: flex; flex-direction: column; gap: 11px; padding: 13px 14px 11px; border-radius: 18px; border: 1px solid var(--oc-field-border); background: var(--oc-field-bg); transition: border-color .18s var(--oc-ease); }
584
+ .oc.focus .field { border-color: var(--oc-ac); }
585
+ .input { width: 100%; border: none; background: transparent; color: var(--oc-fg); font-size: 13.5px; line-height: 1.4; outline: none; padding: 0; resize: none; max-height: 108px; font-family: inherit; }
586
+ .input::placeholder { color: var(--oc-fg-dim); }
587
+ .acts { display: flex; align-items: center; gap: 16px; }
588
+ .act { flex: none; display: flex; border: none; background: transparent; padding: 4px; margin: -4px; color: var(--oc-fg-dim); cursor: pointer; transition: color .18s var(--oc-ease); }
589
+ .act:hover { color: var(--oc-fg-muted); }
590
+ .oc.emoji .act.emoji-btn { color: var(--oc-accent-text); }
591
+ .send { flex: none; margin-left: auto; width: 34px; height: 34px; border-radius: var(--oc-r-full); border: 1px solid var(--oc-border); background: var(--oc-tint-3); color: var(--oc-fg-dim); box-shadow: none; display: flex; align-items: center; justify-content: center; cursor: default; transition: all .22s var(--oc-ease); }
592
+ .oc.ready .send { border-color: var(--oc-btn-rim); background: var(--oc-ac); color: #fff; box-shadow: var(--oc-btn-edge); cursor: pointer; }
593
+ .oc.ready .send:hover { filter: brightness(1.1); }
594
+ .send:active { transform: scale(0.92); }
595
+
596
+ .foot { position: relative; display: none; text-align: center; font-size: 11.5px; line-height: 1.5; color: var(--oc-fg-dim); }
597
+ .oc.has-privacy .foot { display: block; }
598
+ .foot .priv { color: var(--oc-fg-muted); text-decoration: underline; text-underline-offset: 2px; cursor: default; }
599
+ .foot .tip { position: absolute; left: 0; right: 0; bottom: 16px; padding-bottom: 8px; z-index: 7; display: flex; justify-content: flex-end; pointer-events: none; opacity: 0; transition: opacity .18s var(--oc-ease); }
600
+ .oc.max .foot .tip { right: 188px; }
601
+ .foot:hover .tip { opacity: 1; }
602
+ .foot .tip-card { max-width: 100%; width: 262px; padding: 13px 15px; border-radius: 14px; border: 1px solid var(--oc-border); background: var(--oc-bg); box-shadow: 0 8px 22px -12px rgba(0,0,0,0.32); text-align: left; font-size: 11.5px; line-height: 1.55; color: var(--oc-fg-muted); text-wrap: pretty; transform: translateY(6px); transition: transform .24s var(--oc-bounce); }
603
+ .foot:hover .tip-card { transform: none; }
604
+ .foot .tip-card a { color: var(--oc-fg); text-decoration: underline; text-underline-offset: 2px; }
605
+
606
+ /* \u2500\u2500 Emoji picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
607
+ .emoji-scrim { position: absolute; inset: 0; z-index: 3; display: none; }
608
+ .oc.emoji .emoji-scrim { display: block; }
609
+ .emoji-panel { position: absolute; left: 10px; right: 10px; bottom: 96px; z-index: 4; max-height: min(340px, 58%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--oc-border); background: var(--oc-bg); box-shadow: 0 18px 46px -18px rgba(0,0,0,0.6); overflow: hidden; opacity: 0; transform: translateY(10px) scale(0.96); transform-origin: bottom center; pointer-events: none; visibility: hidden; transition: opacity .18s var(--oc-ease), transform .26s var(--oc-bounce), visibility .26s; }
610
+ .oc.emoji .emoji-panel { opacity: 1; transform: none; pointer-events: auto; visibility: visible; }
611
+ .emoji-search { flex: none; display: flex; align-items: center; gap: 11px; padding: 13px 16px; border-bottom: 1px solid var(--oc-border); color: var(--oc-fg-dim); }
612
+ .emoji-search input { flex: 1 1 auto; min-width: 0; border: none; background: transparent; color: var(--oc-fg); font-size: 14.5px; outline: none; font-family: inherit; }
613
+ .emoji-search input::placeholder { color: var(--oc-fg-dim); }
614
+ .emoji-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding: 12px 14px 16px; }
615
+ .emoji-grp { display: flex; flex-direction: column; gap: 6px; }
616
+ .emoji-grp + .emoji-grp { padding-top: 8px; }
617
+ .emoji-grp-name { font-family: var(--oc-mono); font-size: 9.5px; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; color: var(--oc-fg-dim); }
618
+ .emoji-grid { display: grid; grid-template-columns: repeat(7, minmax(0,1fr)); gap: 2px; }
619
+ .emoji-grid button { display: flex; align-items: center; justify-content: center; height: 34px; border-radius: 8px; border: none; background: transparent; font-size: 20px; line-height: 1; cursor: pointer; transition: background .14s var(--oc-ease); }
620
+ .emoji-grid button:hover { background: var(--oc-tint-3); }
621
+ .emoji-empty { display: none; flex-direction: column; align-items: center; gap: 5px; padding: 26px 10px; text-align: center; }
622
+ .emoji-empty .h { font-size: 14px; color: var(--oc-fg); } .emoji-empty .s { font-size: 12.5px; color: var(--oc-fg-dim); }
623
+
624
+ @media (max-width: 480px) {
625
+ :host { right: 0; bottom: 0; }
626
+ .oc { padding: 0 16px 16px; }
627
+ .panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; border-radius: 0; }
628
+ .oc.max .panel { width: 100vw; height: 100dvh; }
629
+ }
630
+ @media (prefers-reduced-motion: reduce) {
631
+ .launcher, .panel, .teaser, .turn, .jump, .emoji-panel { transition: none; animation: none; }
632
+ .ring { animation: none; } .body { scroll-behavior: auto; }
633
+ }
634
+
635
+ @keyframes w-in { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
636
+ @keyframes w-dot { 0%,60%,100% { transform: translateY(0); opacity: .45; } 30% { transform: translateY(-4px); opacity: 1; } }
637
+ @keyframes w-ring { 0% { transform: scale(1); opacity: .5; } 70%,100% { transform: scale(1.7); opacity: 0; } }
638
+ `;
639
+
640
+ // src/icons.ts
641
+ var logoMark = (rectFill) => `<svg viewBox="0 0 1032 1024" fill="none" style="display:block;width:100%;height:100%"><rect x="4" width="1024" height="1024" rx="150" fill="${rectFill}"></rect><path d="M430.924 831.644C270.203 859.983 138.14 760.606 111.329 608.551C84.5174 456.497 174.627 317.945 335.348 289.605C496.068 261.266 628.131 360.643 654.943 512.698C681.754 664.752 591.644 803.304 430.924 831.644ZM406.613 693.77C488.549 679.323 518.727 615.513 505.252 539.092C491.777 462.671 441.594 413.031 359.658 427.478C277.722 441.926 247.544 505.736 261.02 582.157C274.495 658.578 324.677 708.218 406.613 693.77ZM870.337 558.389L744.281 580.616C727.055 482.923 674.565 369.515 652.06 241.884L841.143 208.543C863.509 335.387 853.111 460.696 870.337 558.389ZM910.583 740.568L768.771 765.573L745.016 630.852L886.828 605.846L910.583 740.568Z" fill="#fff"></path></svg>`;
642
+ var stroke = (w, path, size = 24) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" style="display:block;flex:none">${path}</svg>`;
643
+ var ICON = {
644
+ close: (size = 19) => stroke(2.4, '<path d="M18 6 6 18M6 6l12 12"/>', size),
645
+ minus: stroke(2, '<path d="M6 12h12"/>', 15),
646
+ expand: stroke(2, '<path d="M15 4h5v5M20 4l-6.5 6.5M9 20H4v-5M4 20l6.5-6.5"/>', 15),
647
+ shrink: stroke(2, '<path d="M20 9h-5V4M15 9l5.5-5.5M4 15h5v5M9 15l-5.5 5.5"/>', 15),
648
+ chevronDown: stroke(2.1, '<path d="m6 9 6 6 6-6"/>', 16),
649
+ send: stroke(2.2, '<path d="M12 19V5M6 11l6-6 6 6"/>', 16),
650
+ paperclip: stroke(1.7, '<path d="M21 11.5 12.5 20a5.5 5.5 0 0 1-7.8-7.8l8.5-8.5a3.7 3.7 0 0 1 5.2 5.2l-8.5 8.5a1.8 1.8 0 0 1-2.6-2.6l7.9-7.8"/>', 17),
651
+ emoji: stroke(1.7, '<circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/>', 17),
652
+ mic: stroke(1.7, '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>', 17),
653
+ search: stroke(1.9, '<circle cx="11" cy="11" r="7.5"/><path d="m21 21-4.3-4.3"/>', 17),
654
+ clear: stroke(2.2, '<path d="M18 6 6 18M6 6l12 12"/>', 15),
655
+ // Two-page "source" glyph for citation chips.
656
+ book: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" style="display:block;flex:none;color:var(--oc-accent-text)"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H11v18H5.5A2.5 2.5 0 0 1 3 18.5Z"/><path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H13v18h5.5a2.5 2.5 0 0 0 2.5-2.5Z"/></svg>`,
657
+ teaserDismiss: stroke(2.4, '<path d="M18 6 6 18M6 6l12 12"/>', 12)
658
+ };
659
+
660
+ // src/emoji.ts
661
+ var CATS = [
662
+ { name: "Frequently used", items: [
663
+ ["\u{1F44D}", "thumbs up"],
664
+ ["\u{1F64F}", "thanks"],
665
+ ["\u{1F44B}", "wave"],
666
+ ["\u2705", "done"],
667
+ ["\u{1F4DE}", "call"],
668
+ ["\u{1F60A}", "smile"],
669
+ ["\u2764\uFE0F", "heart"]
670
+ ] },
671
+ { name: "Smileys and emotion", items: [
672
+ ["\u{1F600}", "grinning"],
673
+ ["\u{1F603}", "smiley"],
674
+ ["\u{1F604}", "grin"],
675
+ ["\u{1F601}", "beaming"],
676
+ ["\u{1F606}", "squinting"],
677
+ ["\u{1F605}", "relief"],
678
+ ["\u{1F923}", "rolling"],
679
+ ["\u{1F602}", "tears of joy"],
680
+ ["\u{1F642}", "slightly smiling"],
681
+ ["\u{1F643}", "upside down"],
682
+ ["\u{1F609}", "wink"],
683
+ ["\u{1F607}", "halo"],
684
+ ["\u{1F970}", "smiling hearts"],
685
+ ["\u{1F60D}", "heart eyes"],
686
+ ["\u{1F618}", "kiss"],
687
+ ["\u{1F60B}", "yum"],
688
+ ["\u{1F914}", "thinking"],
689
+ ["\u{1F928}", "raised brow"],
690
+ ["\u{1F610}", "neutral"],
691
+ ["\u{1F644}", "eye roll"],
692
+ ["\u{1F62C}", "grimace"],
693
+ ["\u{1F625}", "sad"],
694
+ ["\u{1F622}", "crying"],
695
+ ["\u{1F62D}", "sobbing"],
696
+ ["\u{1F620}", "angry"],
697
+ ["\u{1F633}", "flushed"],
698
+ ["\u{1F929}", "star struck"],
699
+ ["\u{1F634}", "sleeping"]
700
+ ] },
701
+ { name: "Gestures", items: [
702
+ ["\u{1F44E}", "thumbs down"],
703
+ ["\u{1F44F}", "clap"],
704
+ ["\u{1F64C}", "raised hands"],
705
+ ["\u{1F91D}", "handshake"],
706
+ ["\u{1F44C}", "ok hand"],
707
+ ["\u270C\uFE0F", "victory"],
708
+ ["\u{1F4AA}", "strong"],
709
+ ["\u{1F440}", "eyes"],
710
+ ["\u{1F9E0}", "brain"],
711
+ ["\u{1F91F}", "love you"],
712
+ ["\u261D\uFE0F", "point up"],
713
+ ["\u{1F447}", "point down"],
714
+ ["\u{1F91A}", "raised back"],
715
+ ["\u{1F91E}", "fingers crossed"]
716
+ ] },
717
+ { name: "Work and travel", items: [
718
+ ["\u{1F4C5}", "calendar"],
719
+ ["\u{1F4C4}", "document"],
720
+ ["\u{1F4B0}", "money"],
721
+ ["\u{1F4CA}", "chart"],
722
+ ["\u{1F4E6}", "package"],
723
+ ["\u{1F3E5}", "hospital"],
724
+ ["\u{1F3E6}", "bank"],
725
+ ["\u{1F69A}", "truck"],
726
+ ["\u2708\uFE0F", "plane"],
727
+ ["\u{1F4F1}", "phone"],
728
+ ["\u{1F4BB}", "laptop"],
729
+ ["\u{1F50D}", "search"],
730
+ ["\u{1F4CC}", "pin"],
731
+ ["\u{1F514}", "bell"]
732
+ ] },
733
+ { name: "Symbols", items: [
734
+ ["\u{1F525}", "fire"],
735
+ ["\u2728", "sparkles"],
736
+ ["\u{1F389}", "celebrate"],
737
+ ["\u{1F4AF}", "hundred"],
738
+ ["\u26A0\uFE0F", "warning"],
739
+ ["\u274C", "cross"],
740
+ ["\u2B50", "star"],
741
+ ["\u{1F680}", "launch"],
742
+ ["\u{1F49A}", "green heart"],
743
+ ["\u{1F499}", "blue heart"],
744
+ ["\u{1F552}", "clock"],
745
+ ["\u{1F4A1}", "idea"],
746
+ ["\u{1F512}", "lock"],
747
+ ["\u{1F3AF}", "target"]
748
+ ] }
749
+ ];
750
+ var toGroup = (name, items) => ({
751
+ name,
752
+ items: items.map(([ch, kw]) => ({ ch, name: kw }))
753
+ });
754
+ function emojiGroups(query) {
755
+ const q = query.trim().toLowerCase();
756
+ if (!q) return CATS.map((c) => toGroup(c.name, c.items));
757
+ const hits = [];
758
+ for (const c of CATS) for (const e of c.items) {
759
+ if (e[1].indexOf(q) >= 0 && !hits.some((h) => h[0] === e[0])) hits.push(e);
760
+ }
761
+ if (!hits.length) return [];
762
+ return [toGroup(`${hits.length} result${hits.length === 1 ? "" : "s"}`, hits)];
763
+ }
346
764
 
347
765
  // src/element.ts
348
- var HANDOFF_STATUS_TEXT = {
766
+ var DEFAULT_STATUS = "Replies in a few seconds";
767
+ var ATTACHMENT_ACCEPT = "image/*,application/pdf,text/plain,text/csv";
768
+ var AWAY_THRESHOLD = 60;
769
+ var HANDOFF_STATUS = {
349
770
  ai: null,
350
771
  awaiting_human: "Connecting you to a person\u2026",
351
772
  human: "You're chatting with a person"
352
773
  };
353
- var ATTACHMENT_ACCEPT = "image/*,application/pdf,text/plain,text/csv";
354
- var AGENT_TYPING_TEXT = "The agent is typing\u2026";
355
774
  var OyehelloChatElement = class extends HTMLElement {
356
- static observedAttributes = ["api-key", "agent-id", "base-url", "title"];
775
+ static observedAttributes = ["api-key", "agent-id", "base-url", "title", "theme"];
776
+ // ── live client state ──
357
777
  client = null;
358
778
  off = null;
359
779
  messages = [];
360
780
  state = "idle";
361
781
  agentName = null;
362
782
  handoffState = "ai";
363
- // Transient "agent is typing…" affordance (Phase 7 followups) - never a message, only rendered
364
- // while a handoff is active (human/awaiting_human).
365
783
  agentTyping = false;
366
- // Last connection/runtime error message (client emits an 'error' event on a failed start() or
367
- // send). Surfaced as a visible banner so a disabled composer always shows WHY, instead of sitting
368
- // dead. Cleared when the client reaches a healthy state again.
369
784
  lastError = null;
370
- // registerTools() may be called before connectedCallback creates the client (e.g. right after
371
- // document.createElement, before the element is attached). Buffer until the client exists.
372
785
  pendingTools = {};
373
- // Files picked in the file input, staged until the form submits alongside the text.
374
786
  pendingFiles = [];
375
- // Best-effort local thumbnail cache for images THIS widget instance just sent, keyed by filename -
376
- // an instant blob src, no round-trip needed.
377
787
  imagePreviewCache = /* @__PURE__ */ new Map();
378
- // Read-back URLs resolved lazily (via client.getAttachmentUrl) for images with no local blob (a
379
- // reconnect, or an `attachment` SSE echo), keyed by storageRef. Cached across re-renders so the
380
- // same image isn't re-fetched on every render() call.
381
788
  resolvedUrlCache = /* @__PURE__ */ new Map();
789
+ mdCache = /* @__PURE__ */ new Map();
790
+ receivedAt = /* @__PURE__ */ new Map();
791
+ // ── widget UI state ──
792
+ uiOpen = false;
793
+ started = false;
794
+ uiMax = false;
795
+ emojiOpen = false;
796
+ emojiQuery = "";
797
+ away = false;
798
+ // reader scrolled up
799
+ unseen = 0;
800
+ focused = false;
801
+ teaserActive = false;
802
+ renderedCount = 0;
803
+ renderedBotFinal = 0;
804
+ timers = [];
805
+ // ── shadow refs ──
806
+ oc;
807
+ bodyEl;
808
+ textareaEl;
809
+ fileEl;
810
+ titleEl;
811
+ statusEl;
812
+ errEl;
813
+ emojiListEl;
814
+ emojiSearchEl;
382
815
  connectedCallback() {
383
816
  if (!this.shadowRoot) this.attachShadow({ mode: "open" });
384
817
  const apiKey = this.getAttribute("api-key");
@@ -393,23 +826,44 @@ var OyehelloChatElement = class extends HTMLElement {
393
826
  this.client.registerTools(this.pendingTools);
394
827
  this.pendingTools = {};
395
828
  }
396
- this.off = this.client.on((e) => {
397
- if (e.type === "messages") this.messages = e.messages;
398
- else if (e.type === "state") {
399
- this.state = e.state;
400
- if (e.state === "ready" || e.state === "opening") this.lastError = null;
401
- } else if (e.type === "agent") this.agentName = e.agent.name;
402
- else if (e.type === "handoff") this.handoffState = e.state;
403
- else if (e.type === "agentTyping") this.agentTyping = e.active;
404
- else if (e.type === "error") this.lastError = e.error.message;
405
- this.render();
406
- });
407
- this.render();
408
- void this.client.start();
829
+ this.off = this.client.on((e) => this.onClientEvent(e));
830
+ this.renderShell();
831
+ this.renderMessages();
832
+ this.updateHeader();
833
+ this.updateStatus();
834
+ this.updateClasses();
835
+ let wasOpen = false;
836
+ try {
837
+ wasOpen = sessionStorage.getItem(this.key("open")) === "1";
838
+ } catch {
839
+ }
840
+ if (wasOpen) {
841
+ this.openPanel();
842
+ return;
843
+ }
844
+ this.updateClasses(true);
845
+ this.timers.push(setTimeout(() => {
846
+ this.oc?.classList.remove("ring");
847
+ }, 6e3));
848
+ const teaserText = this.getAttribute("teaser");
849
+ let dismissed = false;
850
+ try {
851
+ dismissed = sessionStorage.getItem(this.key("teaser")) === "0";
852
+ } catch {
853
+ }
854
+ if (teaserText && !dismissed) {
855
+ this.timers.push(setTimeout(() => {
856
+ if (!this.uiOpen) {
857
+ this.teaserActive = true;
858
+ this.updateClasses();
859
+ }
860
+ }, 1400));
861
+ }
409
862
  }
410
863
  disconnectedCallback() {
411
864
  this.off?.();
412
865
  void this.client?.end();
866
+ for (const t of this.timers) clearTimeout(t);
413
867
  for (const url of this.imagePreviewCache.values()) {
414
868
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
415
869
  }
@@ -419,37 +873,394 @@ var OyehelloChatElement = class extends HTMLElement {
419
873
  if (this.client) this.client.registerTools(handlers);
420
874
  else Object.assign(this.pendingTools, handlers);
421
875
  }
422
- send(text, files) {
876
+ key(k) {
877
+ return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
878
+ }
879
+ get suggestions() {
880
+ return (this.getAttribute("suggestions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
881
+ }
882
+ // ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
883
+ renderShell() {
884
+ const root = this.shadowRoot;
885
+ const hasTeaser = !!this.getAttribute("teaser");
886
+ const hasPrivacy = !!this.getAttribute("privacy-url");
887
+ const teaserText = this.getAttribute("teaser") ?? "";
888
+ root.innerHTML = `
889
+ <style>${STYLES}</style>
890
+ <div class="oc${hasTeaser ? " has-teaser" : ""}${hasPrivacy ? " has-privacy" : ""}">
891
+ <div class="panel" role="dialog" aria-label="Chat">
892
+ <div class="hd">
893
+ <span class="hd-grid"></span>
894
+ <span class="logo">${logoMark("currentColor")}</span>
895
+ <span class="hd-txt"><span class="hd-title">Chat</span><span class="hd-status"><span class="dot"></span><span class="t"></span></span></span>
896
+ <button type="button" class="hd-btn max" aria-label="Expand"><span class="i i-expand">${ICON.expand}</span><span class="i i-shrink">${ICON.shrink}</span></button>
897
+ <button type="button" class="hd-btn min" aria-label="Minimise">${ICON.minus}</button>
898
+ </div>
899
+ <div class="err" hidden></div>
900
+ <div class="body"></div>
901
+ <span class="emoji-scrim"></span>
902
+ <div class="emoji-panel">
903
+ <div class="emoji-search">${ICON.search}<input type="text" placeholder="Search emoji\u2026" aria-label="Search emoji" /></div>
904
+ <div class="emoji-list"></div>
905
+ </div>
906
+ <div class="jump-wrap"><button type="button" class="jump" aria-label="Jump to latest">${ICON.chevronDown}</button></div>
907
+ <div class="composer">
908
+ <form class="field">
909
+ <textarea class="input" rows="1" aria-label="message" placeholder="Ask a question\u2026"></textarea>
910
+ <div class="acts">
911
+ <button type="button" class="act attach" aria-label="Attach a file">${ICON.paperclip}</button>
912
+ <input type="file" accept="${ATTACHMENT_ACCEPT}" multiple hidden />
913
+ <button type="button" class="act emoji-btn" aria-label="Insert emoji">${ICON.emoji}</button>
914
+ <button type="button" class="act mic" aria-label="Send a voice note">${ICON.mic}</button>
915
+ <button type="submit" class="send" aria-label="Send">${ICON.send}</button>
916
+ </div>
917
+ </form>
918
+ <div class="foot">
919
+ <span>By chatting with us, you agree to our </span><span class="priv">Privacy Policy</span>
920
+ <span class="tip"><span class="tip-card">By chatting with us, you agree to the monitoring and recording of this chat to deliver our services and processing of your personal data in accordance with our Privacy Policy. <a href="${this.escapeAttr(this.getAttribute("privacy-url") ?? "#")}" target="_blank" rel="noopener noreferrer">See our Privacy Policy</a>.</span></span>
921
+ </div>
922
+ </div>
923
+ </div>
924
+
925
+ <div class="teaser">
926
+ <button type="button" class="teaser-btn"><span class="logo">${logoMark("currentColor")}</span><span>${escapeHtml(teaserText)}</span></button>
927
+ <button type="button" class="teaser-x" aria-label="Dismiss">${ICON.teaserDismiss}</button>
928
+ </div>
929
+
930
+ <button type="button" class="launcher" aria-label="Open chat">
931
+ <span class="ring"></span>
932
+ <span class="ic-chat">${logoMark("var(--oc-ac)")}</span>
933
+ <span class="ic-close">${ICON.close(19)}</span>
934
+ <span class="badge"></span>
935
+ </button>
936
+ </div>`;
937
+ this.oc = root.querySelector(".oc");
938
+ this.bodyEl = root.querySelector(".body");
939
+ this.textareaEl = root.querySelector("textarea.input");
940
+ this.fileEl = root.querySelector('input[type="file"]');
941
+ this.titleEl = root.querySelector(".hd-title");
942
+ this.statusEl = root.querySelector(".hd-status .t");
943
+ this.errEl = root.querySelector(".err");
944
+ this.emojiListEl = root.querySelector(".emoji-list");
945
+ this.emojiSearchEl = root.querySelector(".emoji-search input");
946
+ root.querySelector(".launcher").addEventListener("click", () => this.toggle());
947
+ root.querySelector(".hd-btn.min").addEventListener("click", () => this.closePanel());
948
+ root.querySelector(".hd-btn.max").addEventListener("click", () => {
949
+ this.uiMax = !this.uiMax;
950
+ this.updateClasses();
951
+ });
952
+ root.querySelector(".teaser-btn").addEventListener("click", () => this.openPanel());
953
+ root.querySelector(".teaser-x").addEventListener("click", () => this.dismissTeaser());
954
+ root.querySelector(".jump").addEventListener("click", () => this.scrollToBottom());
955
+ root.querySelector(".attach").addEventListener("click", () => this.fileEl.click());
956
+ this.fileEl.addEventListener("change", () => {
957
+ this.pendingFiles = this.fileEl.files ? Array.from(this.fileEl.files) : [];
958
+ });
959
+ root.querySelector(".emoji-btn").addEventListener("click", () => this.toggleEmoji());
960
+ root.querySelector(".emoji-scrim").addEventListener("click", () => this.closeEmoji());
961
+ this.emojiSearchEl.addEventListener("input", () => {
962
+ this.emojiQuery = this.emojiSearchEl.value;
963
+ this.renderEmoji();
964
+ });
965
+ this.bodyEl.addEventListener("scroll", () => this.onScroll());
966
+ root.querySelector("form.field").addEventListener("submit", (e) => {
967
+ e.preventDefault();
968
+ this.doSend();
969
+ });
970
+ this.textareaEl.addEventListener("input", () => {
971
+ this.autoGrow();
972
+ this.updateClasses();
973
+ });
974
+ this.textareaEl.addEventListener("focus", () => {
975
+ this.focused = true;
976
+ this.updateClasses();
977
+ });
978
+ this.textareaEl.addEventListener("blur", () => {
979
+ this.focused = false;
980
+ this.updateClasses();
981
+ });
982
+ this.textareaEl.addEventListener("keydown", (e) => {
983
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
984
+ e.preventDefault();
985
+ this.doSend();
986
+ }
987
+ if (e.key === "Escape") {
988
+ if (this.emojiOpen) this.closeEmoji();
989
+ else this.closePanel();
990
+ }
991
+ });
992
+ }
993
+ renderError(msg) {
994
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
995
+ console.error(msg);
996
+ this.shadowRoot.innerHTML = `<div style="position:fixed;right:16px;bottom:16px;color:#b91c1c;font:12px system-ui;background:#fef2f2;border:1px solid #fecaca;padding:8px 12px;border-radius:8px">${escapeHtml(msg)}</div>`;
997
+ }
998
+ // ── State-class sync ──────────────────────────────────────────────────────────────────────────
999
+ updateClasses(initialRing = false) {
1000
+ if (!this.oc) return;
1001
+ const c = this.oc.classList;
1002
+ c.toggle("open", this.uiOpen);
1003
+ c.toggle("max", this.uiMax);
1004
+ c.toggle("emoji", this.emojiOpen);
1005
+ c.toggle("teaser", this.teaserActive);
1006
+ c.toggle("away", this.away);
1007
+ c.toggle("focus", this.focused);
1008
+ c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1009
+ c.toggle("unread", this.unseen > 0);
1010
+ if (initialRing) c.add("ring");
1011
+ const badge = this.oc.querySelector(".badge");
1012
+ if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1013
+ const launcher = this.oc.querySelector(".launcher");
1014
+ if (launcher) launcher.setAttribute("aria-label", this.uiOpen ? "Close chat" : "Open chat");
1015
+ }
1016
+ // ── Open / close ───────────────────────────────────────────────────────────────────────────────
1017
+ toggle() {
1018
+ this.uiOpen ? this.closePanel() : this.openPanel();
1019
+ }
1020
+ openPanel() {
1021
+ this.uiOpen = true;
1022
+ this.teaserActive = false;
1023
+ this.unseen = 0;
1024
+ try {
1025
+ sessionStorage.setItem(this.key("open"), "1");
1026
+ } catch {
1027
+ }
1028
+ if (!this.started && this.client) {
1029
+ this.started = true;
1030
+ void this.client.start();
1031
+ }
1032
+ this.updateClasses();
1033
+ this.renderMessages();
1034
+ this.scrollToBottom();
1035
+ if (!this.textareaEl.disabled) setTimeout(() => this.textareaEl.focus(), 60);
1036
+ }
1037
+ closePanel() {
1038
+ this.uiOpen = false;
1039
+ this.uiMax = false;
1040
+ this.emojiOpen = false;
1041
+ try {
1042
+ sessionStorage.setItem(this.key("open"), "0");
1043
+ } catch {
1044
+ }
1045
+ this.updateClasses();
1046
+ }
1047
+ dismissTeaser() {
1048
+ this.teaserActive = false;
1049
+ try {
1050
+ sessionStorage.setItem(this.key("teaser"), "0");
1051
+ } catch {
1052
+ }
1053
+ this.updateClasses();
1054
+ }
1055
+ // ── Client events ─────────────────────────────────────────────────────────────────────────────
1056
+ onClientEvent(e) {
1057
+ if (e.type === "messages") this.messages = e.messages;
1058
+ else if (e.type === "state") {
1059
+ this.state = e.state;
1060
+ if (e.state === "ready" || e.state === "opening") this.lastError = null;
1061
+ } else if (e.type === "agent") {
1062
+ this.agentName = e.agent.name;
1063
+ this.updateHeader();
1064
+ } else if (e.type === "handoff") this.handoffState = e.state;
1065
+ else if (e.type === "agentTyping") this.agentTyping = e.active;
1066
+ else if (e.type === "error") this.lastError = e.error.message;
1067
+ this.updateStatus();
1068
+ this.renderMessages();
1069
+ }
1070
+ updateHeader() {
1071
+ if (this.titleEl) this.titleEl.textContent = this.getAttribute("title") ?? this.agentName ?? "Chat";
1072
+ }
1073
+ updateStatus() {
1074
+ if (!this.textareaEl) return;
1075
+ const disabled = this.state !== "ready";
1076
+ this.textareaEl.disabled = disabled;
1077
+ const handoff = HANDOFF_STATUS[this.handoffState];
1078
+ const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1079
+ if (this.statusEl) this.statusEl.textContent = text;
1080
+ if (this.state === "error" && this.lastError) {
1081
+ this.errEl.hidden = false;
1082
+ this.errEl.innerHTML = `<span>Couldn't start chat: ${escapeHtml(this.lastError)}</span><button class="retry" type="button">Retry</button>`;
1083
+ this.errEl.querySelector(".retry").addEventListener("click", () => {
1084
+ void this.client?.start();
1085
+ });
1086
+ } else {
1087
+ this.errEl.hidden = true;
1088
+ this.errEl.innerHTML = "";
1089
+ }
1090
+ }
1091
+ // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1092
+ renderMessages() {
1093
+ if (!this.bodyEl) return;
1094
+ const body = this.bodyEl;
1095
+ const prevTop = body.scrollTop;
1096
+ const added = this.messages.length - this.renderedCount;
1097
+ const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
1098
+ const newBotFinal = botFinal > this.renderedBotFinal;
1099
+ const now = Date.now();
1100
+ for (const m of this.messages) if (!this.receivedAt.has(m.id)) this.receivedAt.set(m.id, now);
1101
+ const lastBot = (() => {
1102
+ for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]?.role === "assistant") return i;
1103
+ return -1;
1104
+ })();
1105
+ let html = this.messages.map((m, i) => this.turnHtml(m, i === lastBot && !this.agentTyping)).join("");
1106
+ if (this.agentTyping) html += '<span class="typing"><span></span><span></span><span></span></span>';
1107
+ const sugg = this.suggestions;
1108
+ if (sugg.length > 0 && !this.agentTyping && this.state === "ready") {
1109
+ html += `<span class="chips">${sugg.map((s, i) => `<button type="button" class="chip" data-chip="${i}">${escapeHtml(s)}</button>`).join("")}</span>`;
1110
+ }
1111
+ body.innerHTML = html;
1112
+ this.hydrateAttachmentImages();
1113
+ this.bindChips();
1114
+ this.renderedCount = this.messages.length;
1115
+ this.renderedBotFinal = botFinal;
1116
+ if (!this.uiOpen) {
1117
+ if (added > 0) this.unseen += added;
1118
+ this.updateClasses();
1119
+ return;
1120
+ }
1121
+ if (newBotFinal && !this.away) this.parkLastQuestion();
1122
+ else if (!this.away) this.scrollToBottom();
1123
+ else {
1124
+ body.scrollTop = prevTop;
1125
+ if (added > 0) this.unseen += added;
1126
+ }
1127
+ this.updateClasses();
1128
+ }
1129
+ // After a reply lands, float the question that prompted it to the top so the answer reads from its
1130
+ // first line rather than its last (the design's behaviour).
1131
+ parkLastQuestion() {
1132
+ const users = this.bodyEl.querySelectorAll(".turn.user");
1133
+ const last = users[users.length - 1];
1134
+ if (!last) {
1135
+ this.scrollToBottom();
1136
+ return;
1137
+ }
1138
+ const top = Math.max(0, Math.min(last.offsetTop - 22, this.bodyEl.scrollHeight - this.bodyEl.clientHeight));
1139
+ this.bodyEl.scrollTo({ top, behavior: "smooth" });
1140
+ }
1141
+ onScroll() {
1142
+ const b = this.bodyEl;
1143
+ const away = b.scrollHeight - b.scrollTop - b.clientHeight > AWAY_THRESHOLD;
1144
+ if (away !== this.away) {
1145
+ this.away = away;
1146
+ if (!away) this.unseen = 0;
1147
+ this.updateClasses();
1148
+ }
1149
+ }
1150
+ scrollToBottom() {
1151
+ if (!this.bodyEl) return;
1152
+ this.bodyEl.scrollTop = this.bodyEl.scrollHeight;
1153
+ this.away = false;
1154
+ this.unseen = 0;
1155
+ this.updateClasses();
1156
+ }
1157
+ // ── Composer ──────────────────────────────────────────────────────────────────────────────────
1158
+ autoGrow() {
1159
+ const ta = this.textareaEl;
1160
+ ta.style.height = "auto";
1161
+ ta.style.height = `${Math.min(ta.scrollHeight, 108)}px`;
1162
+ }
1163
+ doSend() {
1164
+ const text = this.textareaEl.value;
423
1165
  if (!text.trim()) return;
1166
+ const files = this.pendingFiles;
1167
+ this.pendingFiles = [];
1168
+ this.fileEl.value = "";
424
1169
  for (const f of files) {
425
1170
  if (f.type.startsWith("image/") && typeof URL.createObjectURL === "function" && !this.imagePreviewCache.has(f.name)) {
426
1171
  this.imagePreviewCache.set(f.name, URL.createObjectURL(f));
427
1172
  }
428
1173
  }
1174
+ this.away = false;
1175
+ this.emojiOpen = false;
429
1176
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
1177
+ this.textareaEl.value = "";
1178
+ this.textareaEl.style.height = "auto";
1179
+ this.updateClasses();
430
1180
  }
431
- renderError(msg) {
432
- if (!this.shadowRoot) this.attachShadow({ mode: "open" });
433
- this.shadowRoot.innerHTML = `<div style="color:#b91c1c;font:13px system-ui;padding:12px">${escapeHtml(msg)}</div>`;
1181
+ sendChip(text) {
1182
+ this.away = false;
1183
+ this.emojiOpen = false;
1184
+ void this.client?.sendMessage(text);
1185
+ this.updateClasses();
1186
+ }
1187
+ bindChips() {
1188
+ const sugg = this.suggestions;
1189
+ this.bodyEl.querySelectorAll(".chip[data-chip]").forEach((btn) => {
1190
+ const i = Number(btn.getAttribute("data-chip"));
1191
+ const text = sugg[i];
1192
+ if (text) btn.addEventListener("click", () => this.sendChip(text));
1193
+ });
1194
+ }
1195
+ // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1196
+ toggleEmoji() {
1197
+ this.emojiOpen = !this.emojiOpen;
1198
+ this.emojiQuery = "";
1199
+ this.emojiSearchEl.value = "";
1200
+ this.renderEmoji();
1201
+ this.updateClasses();
1202
+ if (this.emojiOpen) setTimeout(() => this.emojiSearchEl.focus(), 60);
1203
+ }
1204
+ closeEmoji() {
1205
+ this.emojiOpen = false;
1206
+ this.emojiQuery = "";
1207
+ this.updateClasses();
1208
+ }
1209
+ renderEmoji() {
1210
+ const groups = emojiGroups(this.emojiQuery);
1211
+ let html = groups.map((g) => `
1212
+ <div class="emoji-grp"><span class="emoji-grp-name">${escapeHtml(g.name)}</span>
1213
+ <div class="emoji-grid">${g.items.map((e) => `<button type="button" title="${this.escapeAttr(e.name)}" aria-label="${this.escapeAttr(e.name)}" data-ch="${this.escapeAttr(e.ch)}">${e.ch}</button>`).join("")}</div>
1214
+ </div>`).join("");
1215
+ if (groups.length === 0) html = '<span class="emoji-empty" style="display:flex"><span class="h">No emoji match that</span><span class="s">Try a shorter word, like smile or call.</span></span>';
1216
+ this.emojiListEl.innerHTML = html;
1217
+ this.emojiListEl.querySelectorAll("button[data-ch]").forEach((btn) => {
1218
+ btn.addEventListener("click", () => {
1219
+ this.textareaEl.value += btn.getAttribute("data-ch") ?? "";
1220
+ this.emojiOpen = false;
1221
+ this.autoGrow();
1222
+ this.updateClasses();
1223
+ this.textareaEl.focus();
1224
+ });
1225
+ });
1226
+ }
1227
+ // ── Bubble rendering ──────────────────────────────────────────────────────────────────────────
1228
+ turnHtml(m, isLastBot) {
1229
+ const role = m.role === "user" ? "user" : m.role === "human" ? "human" : "bot";
1230
+ const atts = (m.attachments ?? []).map((a) => this.renderAttachment(a)).join("");
1231
+ let content;
1232
+ if (m.role === "assistant") {
1233
+ let rendered = m.streaming ? void 0 : this.mdCache.get(m.id);
1234
+ if (rendered === void 0) {
1235
+ rendered = renderMarkdown(m.text || "");
1236
+ if (!m.streaming) this.mdCache.set(m.id, rendered);
1237
+ }
1238
+ content = `<span class="b">${atts}<span class="md">${rendered}</span></span>`;
1239
+ } else {
1240
+ const label = m.role === "human" ? '<div class="human-label">Support</div>' : "";
1241
+ content = `<span class="b">${label}${atts}${escapeHtml(m.text)}</span>`;
1242
+ }
1243
+ const src = m.sources && m.sources.length > 0 ? `<span class="src">${ICON.book}<span class="t">${escapeHtml("From " + (m.sources[0]?.title ?? ""))}</span></span>` : "";
1244
+ const byline = role === "bot" && isLastBot ? `<span class="byline">${escapeHtml((this.agentName ?? "OyeHello") + " \xB7 AI agent \xB7 " + this.relTime(m.id))}</span>` : "";
1245
+ return `<span class="turn ${role}">${content}${src}${byline}</span>`;
1246
+ }
1247
+ relTime(id) {
1248
+ const t = this.receivedAt.get(id);
1249
+ if (!t) return "just now";
1250
+ const mins = Math.floor((Date.now() - t) / 6e4);
1251
+ return mins <= 0 ? "just now" : `${mins} min ago`;
434
1252
  }
435
1253
  renderAttachment(a) {
436
1254
  if (a.contentType.startsWith("image/")) {
437
1255
  const cached = (a.name ? this.imagePreviewCache.get(a.name) : void 0) ?? this.resolvedUrlCache.get(a.storageRef);
438
1256
  const srcAttr = cached ? ` src="${cached}"` : "";
439
- return `<img class="att-thumb" data-oc-ref="${escapeHtml(a.storageRef)}"${srcAttr} alt="${escapeHtml(a.name ?? "image")}"/>`;
1257
+ return `<img class="att-thumb" data-oc-ref="${this.escapeAttr(a.storageRef)}"${srcAttr} alt="${this.escapeAttr(a.name ?? "image")}"/>`;
440
1258
  }
441
1259
  return `<div class="att-chip">\u{1F4CE} ${escapeHtml(a.name ?? a.contentType)}</div>`;
442
1260
  }
443
- renderSource(s) {
444
- return `<div class="src-chip" title="${escapeHtml(s.snippet)}">${escapeHtml(s.title)}</div>`;
445
- }
446
- // After render() attaches the markup, resolve a read-back URL (via the client) for every image
447
- // placeholder that has no src yet - a local blob or a previously-resolved URL already set one, so
448
- // those are left untouched (no redundant network call).
449
1261
  hydrateAttachmentImages() {
450
1262
  if (!this.client) return;
451
- const imgs = this.shadowRoot.querySelectorAll("img.att-thumb[data-oc-ref]");
452
- imgs.forEach((img) => {
1263
+ this.shadowRoot.querySelectorAll("img.att-thumb[data-oc-ref]").forEach((img) => {
453
1264
  if (img.hasAttribute("src")) return;
454
1265
  const ref = img.getAttribute("data-oc-ref");
455
1266
  if (!ref) return;
@@ -460,82 +1271,8 @@ var OyehelloChatElement = class extends HTMLElement {
460
1271
  });
461
1272
  });
462
1273
  }
463
- render() {
464
- const title = this.getAttribute("title") ?? this.agentName ?? "Chat";
465
- const bubbles = this.messages.map((m) => {
466
- const atts = (m.attachments ?? []).map((a) => this.renderAttachment(a)).join("");
467
- const label = m.role === "human" ? '<div class="human-label">Support</div>' : "";
468
- const bubble = `<div class="b ${m.role}">${label}${atts}${m.text ? escapeHtml(m.text) : ""}${m.streaming ? "\u2026" : ""}</div>`;
469
- const sources = m.sources && m.sources.length > 0 ? `<div class="src-row">${m.sources.map((s) => this.renderSource(s)).join("")}</div>` : "";
470
- return bubble + sources;
471
- }).join("");
472
- const statusText = HANDOFF_STATUS_TEXT[this.handoffState];
473
- const status = statusText ? `<div class="status">${escapeHtml(statusText)}</div>` : "";
474
- const errBanner = this.state === "error" && this.lastError ? `<div class="err">Couldn't start chat: ${escapeHtml(this.lastError)}</div>` : "";
475
- const showTyping = this.agentTyping && (this.handoffState === "human" || this.handoffState === "awaiting_human");
476
- const typingIndicator = showTyping ? `<div class="b assistant typing">${escapeHtml(AGENT_TYPING_TEXT)}</div>` : "";
477
- const disabled = this.state !== "ready" ? "disabled" : "";
478
- const prevInput = this.shadowRoot.querySelector('input[aria-label="message"]');
479
- const prevValue = prevInput?.value ?? "";
480
- const prevSelectionStart = prevInput?.selectionStart ?? null;
481
- const prevSelectionEnd = prevInput?.selectionEnd ?? null;
482
- const hadFocus = !!prevInput && this.shadowRoot.activeElement === prevInput;
483
- this.shadowRoot.innerHTML = `
484
- <style>
485
- :host{display:block;height:100%;min-height:320px;font-family:system-ui,sans-serif;--oc-accent:#2563eb}
486
- .wrap{display:flex;flex-direction:column;height:100%;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden}
487
- .hd{padding:10px 14px;border-bottom:1px solid #e5e7eb;font-weight:600}
488
- .body{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
489
- .b{max-width:80%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
490
- .b.user{align-self:flex-end;background:var(--oc-accent);color:#fff}
491
- .b.assistant{align-self:flex-start;background:#f3f4f6;color:#111}
492
- .b.human{align-self:flex-start;background:#dbeafe;color:#111}
493
- .b.typing{font-style:italic;color:#6b7280;opacity:.85}
494
- .human-label{font-size:11px;font-weight:600;opacity:.7;margin-bottom:2px}
495
- .status{padding:6px 14px;font-size:12px;color:#6b7280;background:#f9fafb;border-bottom:1px solid #e5e7eb}
496
- .err{padding:8px 14px;font-size:12px;color:#b91c1c;background:#fef2f2;border-bottom:1px solid #fecaca}
497
- .att-thumb{display:block;max-width:160px;max-height:160px;border-radius:8px;margin-bottom:6px}
498
- .att-chip{display:inline-block;font-size:12px;background:rgba(0,0,0,.08);border-radius:6px;padding:2px 8px;margin:0 0 6px}
499
- .b.user .att-chip{background:rgba(255,255,255,.25)}
500
- .src-row{align-self:flex-start;display:flex;flex-wrap:wrap;gap:6px;max-width:80%}
501
- .src-chip{display:inline-block;font-size:11px;background:rgba(0,0,0,.06);color:#374151;border-radius:6px;padding:2px 8px;cursor:default}
502
- form{display:flex;gap:8px;padding:10px;border-top:1px solid #e5e7eb;align-items:center}
503
- input[aria-label="message"]{flex:1;padding:8px 10px;border-radius:8px;border:1px solid #d1d5db;font:inherit}
504
- input[type="file"]{max-width:120px;font-size:12px}
505
- button{padding:8px 14px;border-radius:8px;border:none;background:var(--oc-accent);color:#fff;font-weight:600;cursor:pointer}
506
- button:disabled,input:disabled{opacity:.6;cursor:not-allowed}
507
- </style>
508
- <div class="wrap">
509
- <div class="hd">${escapeHtml(title)}</div>
510
- ${status}${errBanner}
511
- <div class="body">${bubbles}${typingIndicator}</div>
512
- <form>
513
- <input aria-label="message" placeholder="Type a message\u2026" ${disabled}/>
514
- <input type="file" accept="${ATTACHMENT_ACCEPT}" multiple ${disabled}/>
515
- <button type="submit" ${disabled}>Send</button>
516
- </form>
517
- </div>`;
518
- const form = this.shadowRoot.querySelector("form");
519
- const input = this.shadowRoot.querySelector('input[aria-label="message"]');
520
- const fileInput = this.shadowRoot.querySelector('input[type="file"]');
521
- fileInput.addEventListener("change", () => {
522
- this.pendingFiles = fileInput.files ? Array.from(fileInput.files) : [];
523
- });
524
- form.addEventListener("submit", (e) => {
525
- e.preventDefault();
526
- const files = this.pendingFiles;
527
- this.pendingFiles = [];
528
- this.send(input.value, files);
529
- input.value = "";
530
- });
531
- if (prevValue) {
532
- input.value = prevValue;
533
- if (prevSelectionStart !== null && prevSelectionEnd !== null) input.setSelectionRange(prevSelectionStart, prevSelectionEnd);
534
- }
535
- if (hadFocus) input.focus();
536
- const body = this.shadowRoot.querySelector(".body");
537
- body.scrollTop = body.scrollHeight;
538
- this.hydrateAttachmentImages();
1274
+ escapeAttr(s) {
1275
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
539
1276
  }
540
1277
  };
541
1278
  function escapeHtml(s) {